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.
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/bytesreturns a complete WAV. - Streaming over HTTP →
POST /v1/tts/ssestreams base64 PCM chunks as they render. - Live conversation →
WS /wsholds one connection per call; utterances stream in, audio frames stream out.
Every endpoint on the public API:
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/tts/bytes | One-shot render, returns a full WAV file. |
| POST | /v1/audio/speech | OpenAI-compatible speech; mp3 by default, wav or pcm via response_format. |
| POST | /v1/tts/sse | Streaming synthesis over server-sent events. |
| WS | /ws | Streaming synthesis over a held WebSocket, for live calls. |
| POST | /v1/vapi | Vapi custom-voice contract, raw PCM out. |
| GET | /v1/voices | List the stock voices. |
| GET | /v1/usage | Month-to-date usage for the calling key. |
| POST | /v1/key/usage | The ledger read: what the calling key holds and has spent. |
| GET | /v1/prewarm | Readies a worker in the background, returns right away. |
| POST | /speaker_match | Compare 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 -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
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)
// 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
# 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.
| Stack | What 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. |
| Python | pip install gandr, zero dependencies. Streaming, prewarm and retry built in. |
| Node | npm install gandr, Node 18+, zero dependencies. Same surface as Python. |
| LangChain | pip install gandr-langchain, then GandrText2SpeechTool(voice="gandr-leo") as a standard agent tool. |
| CrewAI | pip install gandr-crewai, then GandrTTSTool() hands any Crew agent a speech tool. |
| Haystack | pip 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. |
| n8n | Settings → Community nodes → n8n-nodes-gandr. A Gandr node in any workflow, no code. |
| LiveKit Agents | One 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. |
| Pipecat | One file, then GandrTTSService(...) in your pipeline. Word timestamps included. |
| Vapi | Two 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 else | REST 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.
| Platform | Base URL change |
|---|---|
| Open WebUI | Admin Panel → Settings → Audio → TTS Base URL: https://tts.gandr.ai/v1 |
| LibreChat | librechat.yaml: tts.openai.url: https://tts.gandr.ai/v1/audio/speech plus tts.openai.apiKey |
| AnythingLLM | Settings → 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 -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:
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}
// 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 readsGANDR_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:
| Mode | Shape | Behavior |
|---|---|---|
| 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).
| ID | Character |
|---|---|
| gandr-jenny | steady, sincere (f) |
| gandr-ava | warm, friendly (f) |
| gandr-mia | easy, unhurried (f) |
| gandr-dane | smooth, measured (m) |
| gandr-leo | clear, professional (m) |
| gandr-lewis | warm, even (m) |
List the roster programmatically, new voices appear here the moment they publish:
curl -s https://tts.gandr.ai/v1/voices -H "x-api-key: $GANDR_KEY"
# {"voices":[{"id":"gandr-ava","name":"Ava","language":"multilingual"}, …]}
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"])
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.
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)
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:
| Voice | Temperature the door assumes |
|---|---|
| gandr-jenny, gandr-ava, gandr-mia, gandr-lewis | 0.5 |
| gandr-dane | 0.65 |
| gandr-leo | 0.8, the floor for a voice the per-voice map does not name. |
| a clone | Not 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).
| Surface | Field | Notes |
|---|---|---|
| POST /v1/tts/bytes · /v1/tts/sse | language | Optional, default en. lang is accepted as an alias. |
| WS /ws | lang | Optional, default en. The socket contract uses lang; sending language there is not part of that contract. |
| POST /v1/vapi | none | Vapi 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. |
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
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.
| Code | Language | Native name | Script | Specimen |
|---|---|---|---|---|
| en | English | English | Latin | published |
| es | Spanish | Español | Latin | published |
| fr | French | Français | Latin | published |
| de | German | Deutsch | Latin | published |
| pt | Portuguese | Português | Latin | published |
| it | Italian | Italiano | Latin | |
| nl | Dutch | Nederlands | Latin | |
| pl | Polish | Polski | Latin | |
| tr | Turkish | Türkçe | Latin | |
| sv | Swedish | Svenska | Latin | |
| da | Danish | Dansk | Latin | |
| no | Norwegian | Norsk | Latin | |
| fi | Finnish | Suomi | Latin | |
| cs | Czech | Čeština | Latin | |
| ro | Romanian | Română | Latin | |
| ru | Russian | Русский | Cyrillic | |
| uk | Ukrainian | Українська | Cyrillic | |
| el | Greek | Ελληνικά | Greek | |
| ar | Arabic | العربية | Arabic (right-to-left) | published |
| hi | Hindi | हिन्दी | Devanagari | |
| zh | Chinese | 中文 | Han | published |
| ja | Japanese | 日本語 | Kana and Kanji | published |
| ko | Korean | 한국어 | 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_errorinstead of the timestamps, and the audio itself is unaffected. - Codes are lowercase two-letter ISO codes, as the table lists them. REST accepts
languageor its aliaslang; the WebSocket contract islang. - No per-request accent or locale control: selection is by base language code, and
GET /v1/voicesreports every stock voice aslanguage: "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.
| Field | Range · what you get if you omit it | What it shapes |
|---|---|---|
| expressiveness | 0.25, 2.0 · nothing sent | Accepted 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. |
| temperature | 0.1, 1.2 · per voice, see below | Prosodic 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_weight | 0.2, 1.0 · nothing sent | Guidance 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. |
| speed | 0.6, 1.5 · 1.0 | Playback rate without pitch change. Applied after synthesis, so wording and voice are untouched. |
| volume | 0.5, 2.0 · 1.0 | Output gain without changing the voice. Applied after mastering with a soft ceiling, so it never hard-clips. |
| seed | integer · unset | Reproducibility: 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.
# 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.
| Control | Behavior |
|---|---|
| <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. |
| emotion | RETIRED 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_dict | Per-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. |
{
"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.
| Value | You get back |
|---|---|
true | word_timestamps |
"word" | word_timestamps |
"char" | char_timestamps as well |
"all" | both word_timestamps and char_timestamps |
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 writes | The 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-0142 | digit by digit, grouped |
| zips, tracking and account numbers | digit 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.
| Field | Type | Range or values | Default | Notes |
|---|---|---|---|---|
| transcript | string | up to 2,000 characters | required | The text to speak; text is accepted as an alias. Inline <spell> and <break> tags ride inside it. |
| voice | object | id or clone modes | required | The speaker: a stock or registered id, or an inline clone from a short reference clip. See Voices & cloning. |
| language | string | ISO code | en | Alias: lang. 23 languages, see Languages. |
| output_format.sample_rate | int | 8000, 16000, 22050, 24000 | 24000 | Native render is 24000; prefer it even for telephony, see Audio output. |
| temperature | number | 0.1-1.2 | chosen per voice | Prosodic 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_weight | number | 0.2-1.0 | nothing sent | Guidance 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. |
| expressiveness | number | 0.25-2.0 | nothing sent | Accepted and inert: nothing you can hear changes, though at a held seed two values still return different audio. Reach for temperature instead. |
| speed | number | 0.6-1.5 | 1.0 | Playback rate without pitch change, applied after synthesis. |
| volume | number | 0.5-2.0 | 1.0 | Output gain with a soft ceiling, applied after mastering, so it never hard-clips. |
| seed | int | any integer | unset | The same seed, text, voice and parameters return the same audio. Omit for a fresh take each time. |
| pronunciation_dict | list | {text, pronunciation} entries | none | Per-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_timestamps | bool or string | true, "word", "char", "all" | false | Timings on the final streaming event, see Timestamps. /v1/tts/bytes returns audio only, so request timestamps over /v1/tts/sse or the WebSocket. |
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.
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
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)
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 field | Type | Meaning |
|---|---|---|
| done | true | Marks the last event. |
| ttfa_ms | int | Server-side time to first audio for this render, in milliseconds. Log it to track the latency you actually receive. |
| audio_ms | int | Length of the rendered audio, in milliseconds. |
| truncated | true | Present only when the render ended early. Treat it as a failure and retry the whole utterance; never play what arrived. |
| word_timestamps | object | Present only with add_timestamps set: {words, start, end}, in seconds from the start of the audio. |
| char_timestamps | object | Present 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.
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>}
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
// 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.
wss://tts.gandr.ai/ws
# headers (either):
x-api-key: gnd_your_key
Authorization: Bearer gnd_your_key
| Field | Type | Notes |
|---|---|---|
| text | string | The utterance to speak. |
| lang | string | Default en. |
| voice_id | string | Your name for the call's voice, any stable string. |
| voice_wav_b64 | string | Reference audio, first utterance only. The voice stays registered for the connection. Omit it to use a stock voice id. |
| temperature … | floats | All six expression controls ride each message; pronunciation_dict and the inline tags work here too. |
| output_sample_rate | int | Default 24000. |
Coming from REST, the fields map one for one:
| REST field | WebSocket field | Notes |
|---|---|---|
| transcript | text | Required. Up to 2,000 characters. |
| language | lang | Default en. |
| voice.id | voice_id | A stock id, or your own name for a voice registered earlier on this socket. |
| voice.wav_b64 | voice_wav_b64 | First utterance only; the fingerprint is cached for the call. |
| output_format.sample_rate | output_sample_rate | Default 24000. |
| the six expression fields, pronunciation_dict, add_timestamps | same names | Same 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 field | When present | Meaning |
|---|---|---|
| ttfa_ms | every stats line | Server time to first audio for this utterance, in milliseconds; the server’s own latency accounting. |
| audio_ms | every stats line | Total audio duration emitted, in milliseconds. |
| truncated | only when true | The render ended early. Retry the utterance, and never play what arrived. |
| word_timestamps | when add_timestamps was set | {words, start, end}, seconds from audio start, aligned against the rendered audio. |
| char_timestamps | with "char" or "all" | The same shape at character granularity. |
| word_timestamps_error | if alignment failed | Returned instead of the timestamps. The audio itself is unaffected. |
In-band errors each arrive as {"error": "…"} on the open socket:
| Error | What it means and what to do |
|---|---|
| invalid_api_key | The key is not recognized. It arrives in band on an open socket, not as a refused handshake. |
| quota_exceeded | The 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_voice | The 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. |
| busy | Soft backpressure under burst load. The socket survives; retry the utterance after about half a second. |
| at_capacity | The same soft backpressure under burst load. Retry the utterance after about half a second. |
# 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
// 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 withvoice_wav_b64. - On
busyorat_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 -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
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.
| Field | Value |
|---|---|
| Server URL | https://tts.gandr.ai/v1/vapi?voice=gandr-mia |
| Secret | gnd_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.
from gandr import Gandr
g = Gandr("gnd_...")
open("confirmation.wav", "wb").write(
g.say("Your table for two is confirmed for Thursday at seven.")
)
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:
# 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.
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.
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
// 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:
| Argument | Default | What it does |
|---|---|---|
| api_key | GANDR_API_KEY | Your gnd_ key. Raises at construction if neither is set. |
| voice | gandr-mia | A stock id, or the id of a registered clone. |
| lang | en | Language of the input text. |
| sample_rate | 24000 | 8000, 16000, 22050 or 24000. Leave it at 24000; see Audio output. |
| speed | unset | 0.6 to 1.5, pitch preserving, applied after synthesis. |
| volume | unset | 0.5 to 2.0, soft-ceiling mastered, never hard-clips. |
| extra | unset | Merged 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_url | tts.gandr.ai | Leave it. |
| timeout | 30.0 | Socket read timeout on the audio stream, in seconds. |
| prewarm_on_start | True | Opens the path as soon as the plugin is built. |
| http_session | unset | Supply 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:
# 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:
"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_rateaccepts 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/bytesreturns a WAV container./v1/tts/sseand/wsstream raw PCM16LE.- All audio is watermarked at generation time for provenance.
- No training on your text or reference audio. Ever.
Limits
| Limit | Value |
|---|---|
| Characters per request | 2,000 |
| Clone reference size | ~1.5 MB base64 (~30 s WAV; 5-10 s is plenty) |
| Request rate | 120 requests / minute / key |
| Monthly characters | Not 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 body | 4 MB at the edge |
| Concurrency | One 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 type | Metering |
|---|---|
| Stream | 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. |
| Token pack | Carries 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 key | Carries 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
| Status | Body | Meaning |
|---|---|---|
| 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.
| Endpoint | Statuses |
|---|---|
| POST /v1/tts/bytes · /v1/tts/sse | The full table above: 400, 401, 402, 429, 503. |
| GET /v1/usage | 401 for a bad key, otherwise 200. |
| GET /v1/prewarm | 202 {"status":"warming"} when it accepts, 401 for a bad key, 503 when no worker is free right now. |
| GET /v1/voices | 200 with the catalog. |
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")
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].