Skip to content

API Reference

API Reference

The Gandr API serves Spex-TTS, our own speech model, over three streaming surfaces: raw WAV bytes, server-sent events, and a live WebSocket built for real-time calls. One request schema across all three. First audio byte in 146 ms over the open internet, 116 ms p50 first audio, server side warm.

Ask Claude CodeAsk ChatGPT

Or read it here. LiveKit guide · Try voices & connect Vapi · Pipecat & the rest · Security

Base URL

tts.gandr.ai

Audio

24 kHz PCM16 native, any rate via resample

Cloning

instant, from 5-10 s of reference audio

Overview

Every endpoint accepts JSON, authenticates with your API key, and starts streaming audio before the render finishes. Voices are either cloned inline from a short reference clip or referenced by a stable id. Delivery is tunable per request, six expression controls and four transcript controls, documented below, ride every surface.

  • One-shot synthesis → POST /v1/tts/bytes returns a complete WAV.
  • Streaming over HTTP → POST /v1/tts/sse streams base64 PCM chunks as they render.
  • Live conversation → WS /ws holds one connection per call; utterances stream in, audio frames stream out.

Every endpoint on the public API:

MethodPathPurpose
POST/v1/tts/bytesOne-shot render, returns a full WAV file.
POST/v1/audio/speechOpenAI-compatible speech; mp3 by default, wav or pcm via response_format.
POST/v1/tts/sseStreaming synthesis over server-sent events.
WS/wsStreaming synthesis over a held WebSocket, for live calls.
POST/v1/vapiVapi custom-voice contract, raw PCM out.
GET/v1/voicesList the stock voices.
GET/v1/usageMonth-to-date usage for the calling key.
POST/v1/key/usageThe ledger read: what the calling key holds and has spent.
GET/v1/prewarmReadies a worker in the background, returns right away.
POST/speaker_matchCompare two reference clips for same-speaker similarity.

Quickstart

Audio in under five minutes: get a key, make one HTTP request, play the WAV that comes back. No SDK required. You need a terminal with curl, Python with requests, or Node 18+.

1. Get a key

Sign in at gandr.ai/join and claim the free key on your console: 50,000 tokens (one token is one character), one per person. Paid keys land the same way, on the dashboard the moment payment settles. Keys look like gnd_…. Treat yours like a password: keep it on your server, never in a browser or a public repo. Set it as an environment variable so the samples below can read it:

export GANDR_KEY=gnd_your_key

The base URL is https://tts.gandr.ai, and every request carries the key in a header, either x-api-key: gnd_your_key or Authorization: Bearer gnd_your_key.

2. Make the first request

POST /v1/tts/bytes takes your text and returns one complete WAV, the simplest call in the API. Each sample writes hello.wav to the current folder.

curl
curl -s https://tts.gandr.ai/v1/tts/bytes \
  -H "x-api-key: $GANDR_KEY" -H "content-type: application/json" \
  -d '{
    "transcript": "Hello from Gandr.",
    "language": "en",
    "voice": {"mode": "id", "id": "gandr-mia"},
    "output_format": {"sample_rate": 24000}
  }' -o hello.wav
python
import os, requests

r = requests.post("https://tts.gandr.ai/v1/tts/bytes",
    headers={"x-api-key": os.environ["GANDR_KEY"]},
    json={"transcript": "Hello from Gandr.",
          "language": "en",
          "voice": {"mode": "id", "id": "gandr-mia"},
          "output_format": {"sample_rate": 24000}})
r.raise_for_status()
open("hello.wav", "wb").write(r.content)
node 18+, save as first-call.mjs and run: node first-call.mjs
// the key rides in a header, so this runs on your server
import { writeFile } from "node:fs/promises";

const res = await fetch("https://tts.gandr.ai/v1/tts/bytes", {
  method: "POST",
  headers: {
    "x-api-key": process.env.GANDR_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    transcript: "Hello from Gandr.",
    language: "en",
    voice: { mode: "id", id: "gandr-mia" },
    output_format: { sample_rate: 24000 },
  }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("hello.wav", Buffer.from(await res.arrayBuffer()));

A 401 {"error":"invalid_api_key"} means the key is missing or wrong. A 402 {"error":"quota_exceeded"} means the characters on the key are spent: buy another pack, or move to a stream, which is not billed by the character. The full list is under Errors.

3. Hear it

hear it back
# macOS
afplay hello.wav
# Linux
aplay hello.wav        # or: ffplay -autoexit hello.wav

Prefer no code at all? The try-it page auditions every stock voice in the browser. Swap the id in the voice object to change speaker; the roster lives under Voices & cloning.

4. Before a live session, prewarm

The fleet is always on; overflow spills to a fallback lane that can take longer on its first request. Before a known call session, fire GET /v1/prewarm: it returns right away and readies a worker in the background, so one is ready by the time you synthesize. The single-file SDKs do this for you. Run it once, right before you know you will synthesize, never on a page load or a hover.

Integrations

Gandr is plain HTTPS and WebSocket, so anything that speaks either will work. These are the paths that are one file or two fields, and they are the ones we test.

StackWhat it takes
MCP (any AI agent)pip install gandr-mcp, then text to speech inside Claude, Cursor, or any MCP-compatible agent. Four tools: synthesize, list_voices, list_languages, get_usage.
Pythonpip install gandr, zero dependencies. Streaming, prewarm and retry built in.
Nodenpm install gandr, Node 18+, zero dependencies. Same surface as Python.
LangChainpip install gandr-langchain, then GandrText2SpeechTool(voice="gandr-leo") as a standard agent tool.
CrewAIpip install gandr-crewai, then GandrTTSTool() hands any Crew agent a speech tool.
Haystackpip install gandr-haystack, then GandrTTS(voice="gandr-mia") as a pipeline component.
AI SDK (JS)npm install gandr-ai-sdk ai, then createGandr() with generateSpeech.
n8nSettings → Community nodes → n8n-nodes-gandr. A Gandr node in any workflow, no code.
LiveKit AgentsOne file next to your agent, then tts=GandrTTS(voice="gandr-mia"). Verified against livekit-agents 1.6.7. Full guide, constructor reference and the measured telephony numbers on its own page.
PipecatOne file, then GandrTTSService(...) in your pipeline. Word timestamps included.
VapiTwo fields in the dashboard, Assistant → Voice → Custom Voice. Server URL https://tts.gandr.ai/v1/vapi?voice=gandr-mia and your key as the secret. The endpoint implements Vapi’s custom-voice contract natively and answers raw PCM at the rate Vapi asks for.
Anything elseREST and WebSocket, documented below. Machine-readable specs at /openapi.yml and /asyncapi.yml. Third-party guides: Gandr-AI/community-integrations.

OpenAI-compatible endpoint

POST /v1/audio/speech implements OpenAI’s speech API. If your tool speaks to OpenAI’s TTS, point it at Gandr and nothing else changes. Full page: /integrations/openai.

PlatformBase URL change
Open WebUIAdmin Panel → Settings → Audio → TTS Base URL: https://tts.gandr.ai/v1
LibreChatlibrechat.yaml: tts.openai.url: https://tts.gandr.ai/v1/audio/speech plus tts.openai.apiKey
AnythingLLMSettings → Speech → Text-to-Speech → Generic OpenAI → Base URL: https://tts.gandr.ai/v1

Voice aliases: alloy → Mia, shimmer → Ava, nova → Jenny, onyx → Dane, echo → Leo, fable → Lewis. Or use Gandr voice ids directly.

curl
curl -X POST https://tts.gandr.ai/v1/audio/speech \
  -H "x-api-key: gnd_..." \
  -H "content-type: application/json" \
  -d '{"input":"Hello from Gandr.","model":"tts-1","voice":"alloy"}' \
  --output speech.wav

Authentication

Send your key on every request. Keys look like gnd_…. A stream key is not billed by the character, a chapter costs the same as a sentence, and a stream is unlimited and unmetered, so there is no budget to watch. A pack key carries exactly the characters it was bought with, and there the number is the product.

# either header works
x-api-key: gnd_your_key
Authorization: Bearer gnd_your_key

Invalid or missing keys return 401 {"error":"invalid_api_key"}.

The same header authenticates every endpoint. The quickest end-to-end check of a key is the ledger read, POST /v1/key/usage, which confirms the key and reports what it holds:

python
import os, requests

r = requests.post("https://tts.gandr.ai/v1/key/usage",
    headers={"x-api-key": os.environ["GANDR_KEY"]})
print(r.json())
# {"name":"gd-xxxx","quota_chars":50000,"chars_used":18250,"remaining":31750}
node 18+
// keep the key on your server, never in a browser
const r = await fetch("https://tts.gandr.ai/v1/key/usage", {
  method: "POST",
  headers: { "x-api-key": process.env.GANDR_KEY },
});
console.log(await r.json());

Key hygiene

  • Keep the key on your server. Never put it in browser code, a mobile bundle, or anything a user can read; the WebSocket already forces this, since a browser cannot set the header.
  • Load it from an environment variable, not a string literal in source. The samples on this page read GANDR_KEY; the LiveKit plugin reads GANDR_API_KEY, and the two single-file SDKs take the key explicitly.
  • Never log the key, paste it into shared tickets, or send its value over email or chat.

Voices & cloning

The voice object selects the speaker. Two modes:

ModeShapeBehavior
clone{"mode":"clone","wav_b64":"<base64 wav>"}Instant clone from 5-10 s of clean reference speech (WAV, base64, ≤ ~1.5 MB / ~30 s). References are fingerprinted and cached, after the first call, re-sending the same clip skips re-cloning.
id{"mode":"id","id":"gandr-…"}A pre-registered voice: one of the stock voices below, or an id registered from your clone.

mode is exactly id or clone. Any other value (a common guess is preset) answers 400 {"error":"voice required"} even though the voice field is present, so if you see that error with a voice in your body, check the mode first. A bare string ("voice": "gandr-mia") is tolerated as an alias for the id form.

Stock voices

Six ready-made voices ship with the API, no reference clip needed, and every one of them is multilingual (set language / lang per request).

IDCharacter
gandr-jennysteady, sincere (f)
gandr-avawarm, friendly (f)
gandr-miaeasy, unhurried (f)
gandr-danesmooth, measured (m)
gandr-leoclear, professional (m)
gandr-lewiswarm, even (m)

List the roster programmatically, new voices appear here the moment they publish:

shell
curl -s https://tts.gandr.ai/v1/voices -H "x-api-key: $GANDR_KEY"
# {"voices":[{"id":"gandr-ava","name":"Ava","language":"multilingual"}, …]}
python
import os, requests

r = requests.get("https://tts.gandr.ai/v1/voices",
    headers={"x-api-key": os.environ["GANDR_KEY"]})
for v in r.json()["voices"]:
    print(v["id"], v["name"], v["language"])
node 18+
const res = await fetch("https://tts.gandr.ai/v1/voices", {
  headers: { "x-api-key": process.env.GANDR_KEY },
});
const { voices } = await res.json();
for (const v of voices) console.log(v.id, v.name, v.language);

The roster is curated, retired ids stop being listed, so pin your integration to GET /v1/voices rather than a hardcoded list. gandr-jenny is built from the “Jenny (Dioco)” speech corpus, used with attribution.

Clones keep the speaker’s identity across languages, synthesize Spanish, German, or Japanese from an English reference and it still sounds like the same person. That cross-language identity is a measured result, not a marketed one: it held up in blind listening panels on speech in languages the reference never spoke.

Cloning in code: the same request as a stock voice, with the voice object carrying the reference clip instead of an id.

python, clone once
import base64, os, requests

wav_b64 = base64.b64encode(open("ref.wav", "rb").read()).decode()

r = requests.post("https://tts.gandr.ai/v1/tts/bytes",
    headers={"x-api-key": os.environ["GANDR_KEY"]},
    json={"transcript": "This is my own voice, cloned from a short clip.",
          "language": "en",
          "voice": {"mode": "clone", "wav_b64": wav_b64},
          "temperature": 0.6,
          "output_format": {"sample_rate": 24000}})
open("out.wav", "wb").write(r.content)
node 18+, clone once
import { readFileSync, writeFileSync } from "node:fs";

const wavB64 = readFileSync("ref.wav").toString("base64");

const res = await fetch("https://tts.gandr.ai/v1/tts/bytes", {
  method: "POST",
  headers: {
    "x-api-key": process.env.GANDR_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    transcript: "This is my own voice, cloned from a short clip.",
    language: "en",
    voice: { mode: "clone", wav_b64: wavB64 },
    temperature: 0.6,
    output_format: { sample_rate: 24000 },
  }),
});
writeFileSync("out.wav", Buffer.from(await res.arrayBuffer()));

What the door assumes when you omit temperature was read for the stock voices; what a clone inherits was not part of that read, so send the field explicitly when you clone:

VoiceTemperature the door assumes
gandr-jenny, gandr-ava, gandr-mia, gandr-lewis0.5
gandr-dane0.65
gandr-leo0.8, the floor for a voice the per-voice map does not name.
a cloneNot measured. Send temperature explicitly on every clone request.

Languages

The engine serves 23 languages, selected per request with one field. Every stock voice is multilingual, and a clone keeps the speaker’s identity across languages, so you set the language and the voice independently. Leave the field out and the request renders English (en).

SurfaceFieldNotes
POST /v1/tts/bytes · /v1/tts/sselanguageOptional, default en. lang is accepted as an alias.
WS /wslangOptional, default en. The socket contract uses lang; sending language there is not part of that contract.
POST /v1/vapinoneVapi posts only text and a sample rate, so this surface carries no per-request language field. Use bytes, sse, or the WebSocket for explicit language control.
rest, one field
curl -s https://tts.gandr.ai/v1/tts/bytes \
  -H "x-api-key: $GANDR_KEY" -H "content-type: application/json" \
  -d '{
    "transcript": "Hola, gracias por llamar hoy.",
    "language": "es",
    "voice": {"mode": "id", "id": "gandr-mia"},
    "output_format": {"sample_rate": 24000}
  }' -o out.wav
websocket, the field is lang
ws.send(JSON.stringify({
  text: "Hola, gracias por llamar hoy.",
  lang: "es",
  voice_id: "gandr-mia",
  output_sample_rate: 24000,
}));

The supported set. The eight marked published have dated specimens rendered on the production API, playable at gandr.ai/languages. Arabic is the one right-to-left script in the set.

CodeLanguageNative nameScriptSpecimen
enEnglishEnglishLatinpublished
esSpanishEspañolLatinpublished
frFrenchFrançaisLatinpublished
deGermanDeutschLatinpublished
ptPortuguesePortuguêsLatinpublished
itItalianItalianoLatin
nlDutchNederlandsLatin
plPolishPolskiLatin
trTurkishTürkçeLatin
svSwedishSvenskaLatin
daDanishDanskLatin
noNorwegianNorskLatin
fiFinnishSuomiLatin
csCzechČeštinaLatin
roRomanianRomânăLatin
ruRussianРусскийCyrillic
ukUkrainianУкраїнськаCyrillic
elGreekΕλληνικάGreek
arArabicالعربيةArabic (right-to-left)published
hiHindiहिन्दीDevanagari
zhChinese中文Hanpublished
jaJapanese日本語Kana and Kanjipublished
koKorean한국어Hangul

All 23 are selectable and served. Eight of them (English, Spanish, French, German, Portuguese, Arabic, Chinese, Japanese) have published, dated specimens generated on the production API and left as they returned. The other fifteen are listed as coverage and run live on the call. The machine-readable notes at /llms.txt mark English as verified and further languages as still in beta.

  • Cross-language cloning: one short reference of 5-10 s carries the speaker’s identity into languages the reference never spoke, a result that held up in blind listening panels. There is no per-language training step; the reference rides inside the request.
  • Automatic readback is English only. Times, money, codes and phone numbers are normalized server-side on English requests; on other languages, write those forms out in words. The inline transcript controls (<spell>, <break>, pronunciation_dict) are separate from this and ride every endpoint.
  • Timestamps are production-verified for English. If alignment fails for a render, the final message carries word_timestamps_error instead of the timestamps, and the audio itself is unaffected.
  • Codes are lowercase two-letter ISO codes, as the table lists them. REST accepts language or its alias lang; the WebSocket contract is lang.
  • No per-request accent or locale control: selection is by base language code, and GET /v1/voices reports every stock voice as language: "multilingual".

Expression controls

Six optional fields ride every endpoint, REST and WebSocket alike, and condition the voice directly, so none of them adds latency. Five of them shape the delivery; expressiveness is still accepted but no longer moves it, and the row below says so rather than leaving you to find out.

FieldRange · what you get if you omit itWhat it shapes
expressiveness0.25, 2.0 · nothing sentAccepted and inert. Your call still succeeds if you send it, and it changes nothing you can hear: the engine remapped the field on 2026-07-29 (measured effect on pitch range +0.33 semitones, p=0.804). It is not discarded the way emotion is, though, at a held seed, two different values return different audio, because it still perturbs the sample. So it is a second seed rather than a dial: leave it off anything you cache or compare byte for byte. The door never fills it in, so it has no default. Reach for temperature instead.
temperature0.1, 1.2 · per voice, see belowProsodic variation, pitch range and melody. 0.1 is locked and monotone; set it low for strict, repeatable IVR lines. This is the only one of the three the door fills in for you, and what it fills in depends on the voice, so send the value you want rather than inheriting one.
cfg_weight0.2, 1.0 · nothing sentGuidance strength, which also sets pacing: 0.2 slower and spacious, 1.0 tight and brisk. Omit it and the door forwards nothing; the engine then rests at its own 0.5.
speed0.6, 1.5 · 1.0Playback rate without pitch change. Applied after synthesis, so wording and voice are untouched.
volume0.5, 2.0 · 1.0Output gain without changing the voice. Applied after mastering with a soft ceiling, so it never hard-clips.
seedinteger · unsetReproducibility: the same seed, text, voice, and parameters return the same audio (per serving region). Omit for a fresh natural take each time.

The temperature the door fills in when you omit the field, per voice: jenny, ava, mia and lewis at 0.5 · dane at 0.65 · leo at 0.8, which is the door’s floor for a voice its per-voice map does not name. What a cloned voice inherits was not part of that reading of the door, so send the field explicitly when you clone. Two requests that differ only in whether the field was present are two different reads, which is why every take on this site sends its dials explicitly.

the expressive recipe
# for emphatic reads, raise temperature (the melody) and
# lower cfg_weight (the pacing) together, so the bigger
# delivery stays unhurried
{"temperature": 0.9, "cfg_weight": 0.3}

Transcript controls

Four inline controls steer the read itself, available on every endpoint, REST and WebSocket.

ControlBehavior
<spell>…</spell>Reads the wrapped text character by character, with letter and digit groups paced naturally. Use it for confirmation codes, order IDs, and serial numbers.
emotionRETIRED on 2026-07-29. The field is still accepted so old clients do not break, but it is discarded before synthesis and has no effect on the audio, the same request at a fixed seed returns identical bytes whichever emotion you name. Shape the read with the transcript itself, and with temperature and cfg_weight.
pronunciation_dictPer-request sounds-like replacements for hard words, proper nouns, domain terms. A lowercase entry also matches its sentence-start capitalized form.
<break time="800ms"/>Inserts a pause at that point, rendered as a natural beat, the tag is never read aloud, and the exact duration is advisory. Well-placed punctuation is still the best pacing tool.
the three live controls, in one request body
{
  "transcript": "Your code is <spell>TKT4829XB</spell>. <break time=\"600ms\"/> Read it back to me.",
  "temperature": 0.9,
  "pronunciation_dict": [
    {"text": "tchoupitoulas",
     "pronunciation": "chop-uh-TOO-liss"}
  ],
  "voice": {"mode": "id", "id": "gandr-jenny"}
}

Timestamps

Set add_timestamps and the final event carries word-level timings aligned against the audio that was actually rendered, captions that match the take, karaoke highlighting, or a barge-in cursor that knows where the agent had got to.

ValueYou get back
trueword_timestamps
"word"word_timestamps
"char"char_timestamps as well
"all"both word_timestamps and char_timestamps
the final event, with add_timestamps: true
data: {"done": true, "ttfa_ms": 129, "audio_ms": 1983,
       "word_timestamps": {
         "words": ["Hello", "there", "friend"],
         "start": [0.08, 0.443, 0.704],
         "end":   [0.302, 0.644, 1.046]}}

Times are seconds from the start of the audio. Alignment runs after the last chunk, so asking for timestamps costs nothing in streaming latency. Production-verified for English. It works the same on the WebSocket, set it on the utterance and the final JSON line carries the same fields.

add_timestamps rides the streaming surfaces: set it on /v1/tts/sse or the WebSocket and the final event carries the timings. /v1/tts/bytes returns audio only, so request timestamps over a streaming endpoint.

Automatic readback

Times, money, codes and phone numbers are normalized server-side on English requests, with nothing to configure. This is the category of mistake a caller notices immediately, and it is the reason most agent teams end up writing a normalization layer, you do not need one.

Your model writesThe caller hears
9:00 AM“nine A M”
$42.50“forty-two dollars and fifty cents”
order OX49“order O X, four nine”
(415) 555-0142digit by digit, grouped
zips, tracking and account numbersdigit by digit

Your own wording always wins, write it out and it is read as written. To force a read, the three transcript controls below (<spell>, <break> and pronunciation_dict) override it.

POST/v1/tts/bytes

Synthesizes the full utterance and returns a complete audio/wav body. Simplest integration; latency equals total render time.

FieldTypeRange or valuesDefaultNotes
transcriptstringup to 2,000 charactersrequiredThe text to speak; text is accepted as an alias. Inline <spell> and <break> tags ride inside it.
voiceobjectid or clone modesrequiredThe speaker: a stock or registered id, or an inline clone from a short reference clip. See Voices & cloning.
languagestringISO codeenAlias: lang. 23 languages, see Languages.
output_format.sample_rateint8000, 16000, 22050, 2400024000Native render is 24000; prefer it even for telephony, see Audio output.
temperaturenumber0.1-1.2chosen per voiceProsodic variation, pitch range and melody; 0.1 is locked and monotone, good for strict, repeatable IVR lines. The one field the door fills in when you omit it, and it fills it in per voice, so send the value you want.
cfg_weightnumber0.2-1.0nothing sentGuidance strength, which also sets pacing: 0.2 slower and spacious, 1.0 tight and brisk. Omitted, the door forwards nothing and the engine rests at its own 0.5.
expressivenessnumber0.25-2.0nothing sentAccepted and inert: nothing you can hear changes, though at a held seed two values still return different audio. Reach for temperature instead.
speednumber0.6-1.51.0Playback rate without pitch change, applied after synthesis.
volumenumber0.5-2.01.0Output gain with a soft ceiling, applied after mastering, so it never hard-clips.
seedintany integerunsetThe same seed, text, voice and parameters return the same audio. Omit for a fresh take each time.
pronunciation_dictlist{text, pronunciation} entriesnonePer-request sounds-like replacements; a lowercase entry also matches its sentence-start capitalized form. (emotion is still accepted and no longer has any effect.)
add_timestampsbool or stringtrue, "word", "char", "all"falseTimings on the final streaming event, see Timestamps. /v1/tts/bytes returns audio only, so request timestamps over /v1/tts/sse or the WebSocket.
curl, clone once, get a WAV back
curl -s https://tts.gandr.ai/v1/tts/bytes \
  -H "x-api-key: $GANDR_KEY" -H "content-type: application/json" \
  -d '{
    "transcript": "Welcome aboard. Your onboarding call starts now.",
    "language": "en",
    "voice": {"mode": "clone", "wav_b64": "'"$(base64 < ref.wav | tr -d '\n')"'"},
    "output_format": {"sample_rate": 24000}
  }' -o out.wav

One complete worked example: a confirmation line for a voice agent, read by gandr-jenny at 24000 Hz with the expressive recipe, a pronunciation fix for a hard proper noun, an inline spell-out for a reference code, a pause, and a seed passed through for future determinism. Automatic readback turns 9:00 AM into “nine A M” on its own.

shell
curl -s https://tts.gandr.ai/v1/tts/bytes \
  -H "x-api-key: $GANDR_KEY" -H "content-type: application/json" \
  -d '{
    "transcript": "Your appointment at the Tchoupitoulas clinic is confirmed. Your reference is <spell>TKT4829XB</spell>. <break time=\"600ms\"/> We will see you Thursday at 9:00 AM.",
    "language": "en",
    "voice": {"mode": "id", "id": "gandr-jenny"},
    "output_format": {"sample_rate": 24000},
    "temperature": 0.9,
    "cfg_weight": 0.3,
    "pronunciation_dict": [{"text": "Tchoupitoulas", "pronunciation": "chop-uh-TOO-liss"}],
    "seed": 42
  }' -o reminder.wav
python
import os, requests

body = {
    "transcript": (
        "Your appointment at the Tchoupitoulas clinic is confirmed. "
        "Your reference is <spell>TKT4829XB</spell>. "
        '<break time="600ms"/> We will see you Thursday at 9:00 AM.'
    ),
    "language": "en",
    "voice": {"mode": "id", "id": "gandr-jenny"},
    "output_format": {"sample_rate": 24000},
    "temperature": 0.9,
    "cfg_weight": 0.3,
    "pronunciation_dict": [{"text": "Tchoupitoulas", "pronunciation": "chop-uh-TOO-liss"}],
    "seed": 42,
}
r = requests.post("https://tts.gandr.ai/v1/tts/bytes",
    headers={"x-api-key": os.environ["GANDR_KEY"]}, json=body)
r.raise_for_status()
open("reminder.wav", "wb").write(r.content)
node 18+
import { writeFile } from "node:fs/promises";

const res = await fetch("https://tts.gandr.ai/v1/tts/bytes", {
  method: "POST",
  headers: {
    "x-api-key": process.env.GANDR_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    transcript:
      `Your appointment at the Tchoupitoulas clinic is confirmed. ` +
      `Your reference is <spell>TKT4829XB</spell>. ` +
      `<break time="600ms"/> We will see you Thursday at 9:00 AM.`,
    language: "en",
    voice: { mode: "id", id: "gandr-jenny" },
    output_format: { sample_rate: 24000 },
    temperature: 0.9,
    cfg_weight: 0.3,
    pronunciation_dict: [{ text: "Tchoupitoulas", pronunciation: "chop-uh-TOO-liss" }],
    seed: 42,
  }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
await writeFile("reminder.wav", Buffer.from(await res.arrayBuffer()));

POST/v1/tts/sse

Same request body as /v1/tts/bytes; the response is a text/event-stream that delivers audio while it renders. Each event carries a base64 chunk of raw PCM16LE at your requested sample rate (no WAV header, it’s a continuous stream). A final event closes the stream and carries the render’s own numbers.

data: {"data": "<base64 pcm16le chunk>"}
data: {"data": "<base64 pcm16le chunk>"}
data: {"done": true, "ttfa_ms": 133, "audio_ms": 2463}

The final event can carry more than the two counters:

Final-event fieldTypeMeaning
donetrueMarks the last event.
ttfa_msintServer-side time to first audio for this render, in milliseconds. Log it to track the latency you actually receive.
audio_msintLength of the rendered audio, in milliseconds.
truncatedtruePresent only when the render ended early. Treat it as a failure and retry the whole utterance; never play what arrived.
word_timestampsobjectPresent only with add_timestamps set: {words, start, end}, in seconds from the start of the audio.
char_timestampsobjectPresent only with add_timestamps "char" or "all": the same shape at character granularity.

Log ttfa_ms and you are monitoring the latency you actually receive, not ours. If a render ends early the final event also carries "truncated": true, treat that as a failure and retry, rather than playing what arrived. Half a sentence read confidently is worse than a retry.

shell, watch the raw stream (-N disables buffering)
curl -N -s https://tts.gandr.ai/v1/tts/sse \
  -H "x-api-key: $GANDR_KEY" -H "content-type: application/json" \
  -d '{"transcript": "The first chunk is already in flight.",
       "voice": {"mode": "id", "id": "gandr-mia"}}'

# data: {"data": "9v/4//r/9f8… (base64 pcm16le)"}
# data: {"data": "AAABAP… (base64 pcm16le)"}
# data: {"done": true, "ttfa_ms": <int>, "audio_ms": <int>}
python, play as it arrives
import base64, json, os, requests

r = requests.post("https://tts.gandr.ai/v1/tts/sse",
    headers={"x-api-key": os.environ["GANDR_KEY"]},
    json={"transcript": "The first chunk is already in flight.",
          "voice": {"mode": "id", "id": "gandr-mia"}},
    stream=True)
r.raise_for_status()                 # 401/402/429/503 surface here
for line in r.iter_lines():          # readline, never a fixed-size read
    if not line.startswith(b"data:"):  # the prefix; a proxy may drop the space
        continue
    evt = json.loads(line[5:])       # json skips any leading space
    if evt.get("done"):
        if evt.get("truncated"):     # ended early: retry, never play it
            raise RuntimeError("stream ended early, retry the utterance")
        break
    pcm = base64.b64decode(evt["data"])  # feed your player
node 18+, fetch and a stream reader (EventSource is GET-only)
// the key rides in a header, so this belongs on your server
const res = await fetch("https://tts.gandr.ai/v1/tts/sse", {
  method: "POST",
  headers: {
    "x-api-key": process.env.GANDR_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    transcript: "The first chunk is already in flight.",
    voice: { mode: "id", id: "gandr-mia" },
  }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop();                       // hold the last, maybe partial, line
  for (const line of lines) {
    if (!line.startsWith("data:")) continue;  // the prefix, never "data: "
    const evt = JSON.parse(line.slice(5));    // JSON.parse skips a leading space
    if (evt.done) {
      if (evt.truncated) throw new Error("ended early, retry the utterance");
      break;
    }
    const pcm = Buffer.from(evt.data, "base64"); // PCM16LE, feed your player
  }
}

WS/ws

The live-call surface, what Gandr itself runs in production for live voice applications. Open one socket per call, keep it for the whole conversation, and send one JSON message per utterance. Audio returns as binary PCM16LE frames, followed by one JSON stats message per utterance.

connect
wss://tts.gandr.ai/ws
# headers (either):
x-api-key: gnd_your_key
Authorization: Bearer gnd_your_key
FieldTypeNotes
textstringThe utterance to speak.
langstringDefault en.
voice_idstringYour name for the call's voice, any stable string.
voice_wav_b64stringReference audio, first utterance only. The voice stays registered for the connection. Omit it to use a stock voice id.
temperature …floatsAll six expression controls ride each message; pronunciation_dict and the inline tags work here too.
output_sample_rateintDefault 24000.

Coming from REST, the fields map one for one:

REST fieldWebSocket fieldNotes
transcripttextRequired. Up to 2,000 characters.
languagelangDefault en.
voice.idvoice_idA stock id, or your own name for a voice registered earlier on this socket.
voice.wav_b64voice_wav_b64First utterance only; the fingerprint is cached for the call.
output_format.sample_rateoutput_sample_rateDefault 24000.
the six expression fields, pronunciation_dict, add_timestampssame namesSame types and ranges as REST; expressiveness stays accepted and inert here too.

Send text of any length, a word or a full paragraph. Multi-sentence input streams as one continuous, gap-free response with no mid-stream stalls, however long the input.

Server → client

Three kinds of message come back. Binary frames are the audio: raw PCM16LE, mono, at your output_sample_rate, streamed as rendered. After each utterance’s audio, one JSON stats line marks the end of the turn. Anything that goes wrong arrives as a JSON error line on the open socket, and the connection stays open, so parse every text frame as JSON and branch on it; a client that only watches for the socket to close will hang.

Stats fieldWhen presentMeaning
ttfa_msevery stats lineServer time to first audio for this utterance, in milliseconds; the server’s own latency accounting.
audio_msevery stats lineTotal audio duration emitted, in milliseconds.
truncatedonly when trueThe render ended early. Retry the utterance, and never play what arrived.
word_timestampswhen add_timestamps was set{words, start, end}, seconds from audio start, aligned against the rendered audio.
char_timestampswith "char" or "all"The same shape at character granularity.
word_timestamps_errorif alignment failedReturned instead of the timestamps. The audio itself is unaffected.

In-band errors each arrive as {"error": "…"} on the open socket:

ErrorWhat it means and what to do
invalid_api_keyThe key is not recognized. It arrives in band on an open socket, not as a refused handshake.
quota_exceededThe characters this key was bought with are spent, the same ceiling REST answers 402 for. Buy another pack, or move to a stream, which is not billed by the character.
need_voiceThe voice is not registered on this connection, for example after reconnecting to a different node. Resend the same utterance with voice_wav_b64, then carry on.
busySoft backpressure under burst load. The socket survives; retry the utterance after about half a second.
at_capacityThe same soft backpressure under burst load. Retry the utterance after about half a second.
python, one live call
# pip install websockets. The key travels in a header and a browser
# cannot set one, so this socket belongs on your server.
import json, os
from websockets.sync.client import connect

msg = {
    "text": "Hola, gracias por llamar hoy.", "lang": "es",
    "voice_id": "gandr-mia",       # or voice_wav_b64 on the first turn
    "temperature": 0.9, "cfg_weight": 0.3, "output_sample_rate": 24000,
}

with connect("wss://tts.gandr.ai/ws",
             additional_headers={"x-api-key": os.environ["GANDR_KEY"]},
             max_size=None) as ws:   # audio frames can be large
    ws.send(json.dumps(msg))
    with open("turn.pcm", "wb") as out:          # PCM16LE @ 24 kHz
        while True:
            frame = ws.recv()
            if isinstance(frame, (bytes, bytearray)):
                out.write(frame)                 # audio: play or buffer it
                continue
            evt = json.loads(frame)              # one JSON line
            if evt.get("error") == "need_voice":
                # resend msg with voice_wav_b64 set, then carry on
                raise RuntimeError("need_voice")
            if evt.get("error"):                 # busy / at_capacity:
                raise RuntimeError(evt["error"]) # retry after ~0.5 s
            if evt.get("truncated"):             # ended early: retry it,
                raise RuntimeError("truncated")  # never play a half read
            print("utterance stats", evt)        # {"ttfa_ms": …, "audio_ms": …}
            break                                # ready for the next turn
node, one live call
// npm install ws. The key travels in a header and a browser
// cannot set one, so this socket belongs on your server.
const fs = require("fs");
const WebSocket = require("ws");

const ws = new WebSocket("wss://tts.gandr.ai/ws", {
  headers: { "x-api-key": process.env.GANDR_KEY },
});
const out = fs.createWriteStream("turn.pcm");  // PCM16LE @ 24 kHz

ws.on("open", () => ws.send(JSON.stringify({
  text: "Hola, gracias por llamar hoy.", lang: "es",
  voice_id: "gandr-mia",        // or voice_wav_b64 on the first turn
  temperature: 0.9, cfg_weight: 0.3, output_sample_rate: 24000,
})));

ws.on("message", (data, isBinary) => {
  if (isBinary) out.write(data);                          // audio
  else console.log("utterance stats", JSON.parse(data));  // {ttfa_ms, audio_ms}
});

Handling checklist

  • Run the socket server-side; a browser cannot set the auth header.
  • Parse every text frame as JSON. Refusals arrive in band on an open socket, including invalid_api_key, so never wait for a close event to notice a problem.
  • Treat the stats line as end of utterance, then send the next one. One utterance renders at a time per socket.
  • On "truncated": true, retry the utterance. Never play a partial read.
  • On need_voice, resend the utterance with voice_wav_b64.
  • On busy or at_capacity, retry the utterance after about half a second. The socket survives.
  • Lift your client’s max frame size; audio frames can be large.

GET/v1/prewarm

The fleet is always on. Fire a prewarm the moment a call starts, the SIP invite is the natural trigger. It returns immediately and readies a worker in the background, so the pipeline is warm by the time audio is needed.

curl https://tts.gandr.ai/v1/prewarm \
  -H "Authorization: Bearer $GANDR_KEY"
# {"status":"warming"} , returns immediately; the warm-up
# continues behind it

Skip it and the first request of a session can be materially slower: overflow spills to a fallback lane that can take longer on its first request. Both SDKs below fire the prewarm for you.

GET/v1/usage

Returns the calling key’s current-month character usage, and, on trial keys, the remaining quota. Authenticate the same way as every other endpoint.

curl -s https://tts.gandr.ai/v1/usage -H "x-api-key: $GANDR_KEY"
# {"key_id":"…","month":"2026-07","monthly_char_quota":…,
#  "chars":3433,"audio_s":207.2,"requests":46}

For a metering read, prefer /v1/key/usage below: it answers from the ledger itself, so it is the cheaper call to poll from a dashboard or a budget alarm.

POST/v1/key/usage

The ledger read: what the calling key holds and what it has spent, served straight from the quota ledger. Any method works, so a plain GET is fine too. This is what the console’s own Usage panel reads.

curl -s -X POST https://tts.gandr.ai/v1/key/usage -H "x-api-key: $GANDR_KEY"
# {"name":"gd-xxxx","quota_chars":50000,"chars_used":18250,"remaining":31750}

A key that is missing or malformed answers 401. The counts are characters, and one token is one character.

POST/speaker_match

Same-speaker verification for clone enrollment. Send two reference clips and get back the cosine similarity of their speaker embeddings (the same speaker encoder the clone path conditions on). Use it to check that a new reference clip belongs to the speaker you already enrolled before accepting it as a clone source.

curl, score two clips
curl -s https://tts.gandr.ai/speaker_match \
  -H "Authorization: Bearer $GANDR_KEY" -H "content-type: application/json" \
  -d '{"wav_a_b64":"<base64 wav>","wav_b_b64":"<base64 wav>"}'
# {"cosine": 0.91}, same speaker scores near 1.0
python, gate enrollment on the score
import base64, os, requests

def b64(path):
    return base64.b64encode(open(path, "rb").read()).decode()

r = requests.post(
    "https://tts.gandr.ai/speaker_match",
    headers={"Authorization": f"Bearer {os.environ['GANDR_KEY']}"},
    json={"wav_a_b64": b64("enrolled.wav"), "wav_b_b64": b64("new.wav")},
)
print(r.json()["cosine"])   # near 1.0 for the same speaker

Both clips follow the same limits as clone references (WAV, base64, ≤ ~1.5 MB / ~30 s). Unrelated speakers land far lower than same-speaker pairs; pick the threshold that fits your enrollment policy.

POST/v1/vapi

Vapi’s custom-voice contract, implemented natively. There is no code on your side: in the Vapi dashboard go to Assistant → Voice → Custom Voice and set two fields.

FieldValue
Server URLhttps://tts.gandr.ai/v1/vapi?voice=gandr-mia
Secretgnd_your_key

Vapi sends the secret as X-VAPI-SECRET and posts {"message":{"type":"voice-request","text":…,"sampleRate":…}}. The endpoint answers raw PCM s16le mono at the rate Vapi asks for, 8000 on a telephony assistant. Change the voice with the ?voice= parameter; nothing else moves.

There is also a one-click path: open the try-it page, paste your Gandr key and your Vapi key, and it finds your assistants and configures them.

SDKs

The official clients are published packages now: pip install gandr (Python 3.9+, zero dependencies) or npm install gandr (Node 18+, zero dependencies). Both wrap the three calls that matter, prewarm, say and stream, and auto-prewarm and retry with backoff over multiple attempts through an overflow response, so a first call after an idle period just works.

pip install gandr
from gandr import Gandr

g = Gandr("gnd_...")
open("confirmation.wav", "wb").write(
  g.say("Your table for two is confirmed for Thursday at seven.")
)
npm install gandr
import { Gandr } from "gandr";

const g = new Gandr("gnd_...");
const wav = await g.say("Your table for two is confirmed.");

Need a dependency-free file you can vendor beside your own code? The originals are still served from this domain, one file each:

download, one file each
# python  (needs: pip install requests websockets)
curl -O https://gandr.ai/sdk/gandr_tts.py

# node 18+  (needs: npm install ws)
curl -O https://gandr.ai/sdk/gandr-tts.js

# pipecat service
curl -O https://gandr.ai/sdk/gandr_pipecat.py

# livekit agents plugin, see /integrations/livekit
curl -O https://gandr.ai/integrations/livekit/gandr_tts.py

One thing to know before you download: the standalone Python SDK and the LiveKit plugin are both single files named gandr_tts.py, and both are imported as from gandr_tts import GandrTTS, but they are different files for different jobs. The SDK gives you say and stream outside any framework; the LiveKit file is a livekit-agents TTS plugin. Download the one for your path, and never put both in the same directory.

python
from gandr_tts import GandrTTS

tts = GandrTTS("gnd_your_key")
tts.prewarm()

# or voice_wav="ref.wav" on either call, to clone instead
open("out.wav", "wb").write(tts.say("Hello.", voice_id="gandr-mia"))

with open("live.pcm", "wb") as f:                 # PCM16LE @ 24 kHz
    for pcm in tts.stream("Live call speech.", voice_id="gandr-mia"):
        f.write(pcm)

Per-utterance extras ride through extra=, and the final stats land on last_stats after a stream completes. Anything the REST schema accepts, pronunciation_dict, temperature, cfg_weight, seed, rides along.

python, timestamps through the SDK
for pcm in tts.stream("Read this back.", voice_id="gandr-mia",
                      extra={"add_timestamps": "word"}):
    play(pcm)

tts.last_stats["word_timestamps"]   # also ttfa_ms, audio_ms
node
// gandr-tts.js sits next to this file
const fs = require("fs");
const { GandrTTS } = require("./gandr-tts");

const tts = new GandrTTS("gnd_your_key");

async function main() {
  await tts.prewarm();
  // or { voiceWav: "ref.wav" } on either call, to clone instead
  fs.writeFileSync("out.wav", await tts.say("Hello.", { voiceId: "gandr-mia" }));

  const live = fs.createWriteStream("live.pcm");  // PCM16LE @ 24 kHz
  for await (const pcm of tts.stream("Live call speech.", { voiceId: "gandr-mia" }))
    live.write(pcm);
}
main();

In Node the same extras go in { extra: {…} }, and the final stats are on tts.lastStats.

LiveKit Agents plugin

The plugin implements the standard livekit-agents TTS interface, so STT, LLM and turn handling stay as they are, and it is the one path here that reads GANDR_API_KEY from the environment. tts goes on AgentSession, not on Agent. The full guide lives on the LiveKit page; the constructor takes:

ArgumentDefaultWhat it does
api_keyGANDR_API_KEYYour gnd_ key. Raises at construction if neither is set.
voicegandr-miaA stock id, or the id of a registered clone.
langenLanguage of the input text.
sample_rate240008000, 16000, 22050 or 24000. Leave it at 24000; see Audio output.
speedunset0.6 to 1.5, pitch preserving, applied after synthesis.
volumeunset0.5 to 2.0, soft-ceiling mastered, never hard-clips.
extraunsetMerged into every request: pronunciation_dict, temperature, cfg_weight, seed. Omit temperature and the door picks per voice; omit cfg_weight and nothing is sent.
base_urltts.gandr.aiLeave it.
timeout30.0Socket read timeout on the audio stream, in seconds.
prewarm_on_startTrueOpens the path as soon as the plugin is built.
http_sessionunsetSupply your own HTTP session instead of the plugin creating one.

Pipecat

gandr_pipecat.py gives you a GandrTTSService that drops into a Pipecat pipeline, word timestamps included, key passed explicitly:

python, pipecat
# gandr_pipecat.py sits next to your pipeline
from gandr_pipecat import GandrTTSService

tts = GandrTTSService(api_key="gnd_your_key", voice="gandr-mia",
                      word_timestamps=True)
pipeline = Pipeline([..., llm, tts, transport.output(), ...])

AI coding agents

The fastest path is not to read this page. Press Copy docs for AI at the top, paste it into Claude Code, Cursor, Copilot or any agent, and say:

Integrate Gandr TTS into this project.

What it copies carries every endpoint, the full request body, both streaming protocols, the integration snippets and the error semantics, so your agent wires up streaming, timestamps and fallback without you reading another word. The Ask Claude Code and Ask ChatGPT buttons do the same thing in one click.

Want Gandr as a tool your agent can call on its own? Install the Gandr MCP server and point any MCP client at it:

pip install gandr-mcp · then, in your MCP client
"mcpServers": {
  "gandr": {
    "command": "gandr-mcp",
    "env": { "GANDR_API_KEY": "gnd_..." }
  }
}

It exposes synthesize, list_voices, list_languages and get_usage, so a model can speak, check the voices, pick a language and read the key’s remaining tokens without any more integration than the config above. Works in Claude Desktop, Cursor, and anything else that speaks MCP.

Machine-readable specs, for generating a client or wiring the socket by hand: /openapi.yml and /asyncapi.yml.

Audio output

  • Native render is 24 kHz mono PCM, and sample_rate accepts 8000, 16000, 22050 or 24000. Prefer 24000 even on a telephony call. Resampling is server-side and happens before the first chunk ships: 8 kHz measures materially slower to first audio than every other rate (measured 2026-07-31, five runs per rate per voice on warm workers). Read those rows against each other, the sitewide first-audio figure is a separate, later run. Let your SIP stack do the downsample, it is free there. If you need a narrowband source, 16000 costs nothing against 24000.
  • /v1/tts/bytes returns a WAV container. /v1/tts/sse and /ws stream raw PCM16LE.
  • All audio is watermarked at generation time for provenance.
  • No training on your text or reference audio. Ever.

Limits

LimitValue
Characters per request2,000
Clone reference size~1.5 MB base64 (~30 s WAV; 5-10 s is plenty)
Request rate120 requests / minute / key
Monthly charactersNot counted on a stream, a stream is unlimited and unmetered. A token pack holds the characters it was bought with. GET /v1/usage reports the count, kept per door, so read it as approximate
Request body4 MB at the edge
ConcurrencyOne utterance renders at a time per stream; utterances on a socket are serialized.

Metering is in characters, and one token is one character. A stream is not billed by the character: a chapter costs the same as a sentence, and there is no budget to watch. A token pack is prepaid credit denominated in characters; each request spends the characters in its transcript, and when the balance is spent the next request answers 402. The count is kept per door, so treat a remaining figure as approximate. Read the ledger with POST /v1/key/usage.

The split matters: 402 means the characters are spent, so retrying the same request fails again, buy another pack or move to a stream instead. 429 (over 120 requests a minute) and 503 (momentarily at capacity) are transient, so back off briefly and retry. It is 402 and not 429 on purpose: 429 tells a client to slow down and retry, which is the wrong instruction when the real problem is an empty balance.

Getting a key

Sign in at gandr.ai/join and claim the free key on your console: 50,000 tokens (one token is one character), one per person. Paid keys land the same way, on the dashboard the moment payment settles. From 500 lines, apply at gandr.ai/waitlist instead and we size the fleet together, because a stream is capacity held open for you whether you are speaking through it or not.

Key typeMetering
StreamNot billed by the character. A chapter costs the same as a sentence, and a stream is unlimited and unmetered, so there is no budget to watch.
Token packCarries exactly the characters it was bought with, and there the number is the product. When they are spent, requests answer 402 quota_exceeded: buy another pack, or move to a stream.
Free keyCarries 50,000 tokens (one token is one character). One per person, claimed on your console after signing in.

Read where any key stands with POST /v1/key/usage, the ledger read documented above; it reports quota, spend and remainder in characters.

With a key in hand, the try-it page auditions every voice and one-click-connects your Vapi assistants. Everything else on this page works from a terminal.

Errors

StatusBodyMeaning
400{"error":"bad_json"}Body isn't valid JSON.
400{"error":"transcript required, <= 2000 chars"}Missing or oversized transcript.
400{"error":"voice required: …"}No valid voice object (bad mode, unknown id, or oversized reference).
401{"error":"invalid_api_key"}Missing or unrecognized key.
402{"error":"quota_exceeded","used_chars":…,"quota_chars":…,"requested_chars":…}The characters this key was bought with are spent. Not a rate limit and not retryable, buy another pack, or move to a stream, which is not billed by the character and is unlimited and unmetered. The count is kept per door, so treat a remaining figure as approximate.
429{"error":"rate_limited"}Over 120 requests/min, back off briefly.
503{"error":"at_capacity"}Node is saturated; retry with backoff, the fleet scales within seconds.

The WebSocket answers in-band and keeps the connection: {"error":"busy"} is soft backpressure and an immediate retry succeeds as capacity frees; {"error":"quota_exceeded"} is the same character ceiling as the 402 above; and a key the door does not know comes back as {"error":"invalid_api_key"} on the open socket rather than as a refused handshake.

Retry by class: 400 and 401 are problems with the request itself, and retrying sends the same broken request, so fix it instead of looping. 429 and 503 are transient, back off (an exponential delay works well) and retry. 402 is not retryable at all. Retries are safe to send, identical requests may be served from cache; pass a seed when you need the exact same audio back on purpose.

EndpointStatuses
POST /v1/tts/bytes · /v1/tts/sseThe full table above: 400, 401, 402, 429, 503.
GET /v1/usage401 for a bad key, otherwise 200.
GET /v1/prewarm202 {"status":"warming"} when it accepts, 401 for a bad key, 503 when no worker is free right now.
GET /v1/voices200 with the catalog.
python, retry the transient codes only
import os, time, requests

URL = "https://tts.gandr.ai/v1/tts/bytes"
HEADERS = {"x-api-key": os.environ["GANDR_KEY"]}
BODY = {
    "transcript": "Hello from Gandr.",
    "voice": {"mode": "id", "id": "gandr-mia"},
}

def synthesize(retries=4):
    for attempt in range(retries):
        r = requests.post(URL, headers=HEADERS, json=BODY)

        if r.status_code == 200:
            return r.content                       # the WAV bytes

        if r.status_code in (429, 503):            # transient: back off and retry
            time.sleep(2 ** attempt)
            continue

        err = r.json()                             # every error is JSON
        if r.status_code == 402:                   # characters spent; retrying is no help
            raise RuntimeError(f"out of characters: {err}")

        # 400 or 401: the request itself is wrong, do not retry
        raise RuntimeError(f"{r.status_code}: {err['error']}")

    raise RuntimeError("still at capacity after retries")
node 18+, same policy
const URL = "https://tts.gandr.ai/v1/tts/bytes";
const HEADERS = {
  "x-api-key": process.env.GANDR_KEY,
  "content-type": "application/json",
};
const BODY = JSON.stringify({
  transcript: "Hello from Gandr.",
  voice: { mode: "id", id: "gandr-mia" },
});

async function synthesize(retries = 4) {
  for (let attempt = 0; attempt < retries; attempt++) {
    const res = await fetch(URL, { method: "POST", headers: HEADERS, body: BODY });

    if (res.ok) return Buffer.from(await res.arrayBuffer());   // the WAV bytes

    if (res.status === 429 || res.status === 503) {            // transient: back off
      await new Promise((r) => setTimeout(r, 2 ** attempt * 1000));
      continue;
    }

    const err = await res.json();                              // every error is JSON
    if (res.status === 402) {                                  // characters spent
      throw new Error(`out of characters: ${err.error}`);
    }

    // 400 or 401: the request itself is wrong, do not retry
    throw new Error(`${res.status}: ${err.error}`);
  }
  throw new Error("still at capacity after retries");
}

Questions, higher limits, or a pilot key: [email protected].