Going to Production
Keep your key safe, handle errors and limits, and ship with confidence
Before you launch, make sure your API key stays private, your code handles errors and limits properly, and your API credit can't run dry unnoticed.
Keep your key on the server
Call Voice213 from your backend, never from browser or mobile app code. A key shipped to a client can be extracted and used to spend your API credit. The API also refuses cross-origin requests from other websites' browser code, so a front-end call fails with a CORS error anyway.
The usual pattern is a small endpoint on your server that your front end calls:
app.post("/api/speak", async (req, res) => {
// Authenticate your own user here, and validate what they send
const response = await fetch("https://api.voice213.com/api/v1/tts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VOICE213_API_KEY}`,
"Content-Type": "application/json",
},
// voiceId: the inline format, so the WAV comes back in the response
body: JSON.stringify({ text: String(req.body.text ?? "").slice(0, 3000), model: "voice213-lite", voiceId: "nariman", dialect: 1 }),
});
const result = await response.json();
if (!response.ok) return res.status(response.status).json({ error: result.message ?? result.error });
// "data:audio/wav;base64,<audio>" → the WAV file
const [meta, audio] = result.data.audio.split(",");
res.type(meta.slice(5, meta.indexOf(";"))).send(Buffer.from(audio, "base64"));
});import base64
import os
import httpx
from fastapi import FastAPI, HTTPException, Response
app = FastAPI()
@app.post("/api/speak")
async def speak(payload: dict):
# Authenticate your own user here, and validate what they send
async with httpx.AsyncClient(timeout=300) as client:
response = await client.post(
"https://api.voice213.com/api/v1/tts",
headers={"Authorization": f"Bearer {os.environ['VOICE213_API_KEY']}"},
# voiceId: the inline format, so the WAV comes back in the response
json={"text": str(payload.get("text", ""))[:3000], "model": "voice213-lite", "voiceId": "nariman", "dialect": 1},
)
result = response.json()
if response.is_error:
raise HTTPException(response.status_code, result.get("message") or result.get("error"))
# "data:audio/wav;base64,<audio>" → the WAV file
meta, audio = result["data"]["audio"].split(",", 1)
return Response(base64.b64decode(audio), media_type=meta[5 : meta.index(";")])Put your own authentication and rate limiting in front of an endpoint like this. Otherwise anyone who finds it can spend your API credit through it.
Protect your key
Each account has one API key. The API Keys page shows when it last made a successful voice-over, and Usage breaks down your calls day by day.
If the key may have leaked, switch it off on the API Keys page right away: requests that use it are refused at once. Then delete it, create a new one, and deploy the new key.
Handle errors and retry safely
Every error response is JSON with a readable message in error:
{ "error": "Rate limit exceeded: RPM limit exceeded", "limit": 600 }When your API credit runs out, error holds the code INSUFFICIENT_CREDIT and message the explanation, with the amounts:
{
"error": "INSUFFICIENT_CREDIT",
"message": "Not enough API credit. Top up your balance in the developer workspace.",
"required": 3.6,
"balance": 0.12,
"currency": "DZD"
}Retry only the errors that can succeed on a second attempt:
| Status | Retry? | What to do |
|---|---|---|
400 | No | Fix the request. The message says what's wrong. |
401 | No | Check the API key, and that it's sent as Authorization: Bearer <key> or in x-api-key. |
402 | After a top-up, or once running voice-overs end | Top up your API credit, then retry. If your other running voice-overs have set the rest of it aside, you can also retry once they end. |
403 | No | The account is deactivated or suspended, or its API access is switched off (API_ACCESS_BLOCKED). Contact support. |
410 | No | The endpoint was removed from the API: remove the call. See Removed endpoints. |
413 | No | Send a smaller body (the limit is 25 MB). |
429 | Yes | Wait for the minute (or the day) to pass, or for one of your 20 running voice-overs to end. See Rate Limits. |
500, 502 | Yes | Retry with exponential backoff. |
503 | Later | Voice-overs through the API are paused, or the API is down for maintenance. For MODEL_DISABLED, switch to another model instead. See Paused models and maintenance. |
Voice213 charges a request only once its generation has succeeded, so a failed request costs nothing and retrying it is safe. Here's a small wrapper that retries with backoff:
import os
import time
import requests
RETRYABLE = {429, 500, 502, 503, 504}
MAX_WAIT = 60 # seconds
def synthesize(payload, attempts=5):
for attempt in range(attempts):
response = requests.post(
"https://api.voice213.com/api/v1/tts",
headers={"Authorization": f"Bearer {os.environ['VOICE213_API_KEY']}"},
json=payload,
timeout=300,
)
if response.ok:
return response.json()
try:
body = response.json()
except ValueError:
body = {}
# A daily limit or a paused model won't clear within a few retries
lasting = body.get("code") == "MODEL_DISABLED" or "Daily limit" in str(body.get("error", ""))
if response.status_code not in RETRYABLE or lasting or attempt == attempts - 1:
response.raise_for_status()
# Honor Retry-After when it's sent (capped), otherwise back off exponentially
time.sleep(min(float(response.headers.get("Retry-After", 2 ** (attempt + 1))), MAX_WAIT))const RETRYABLE = new Set([429, 500, 502, 503, 504]);
const MAX_WAIT = 60; // seconds
async function synthesize(payload, attempts = 5) {
for (let attempt = 0; ; attempt++) {
const response = await fetch("https://api.voice213.com/api/v1/tts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VOICE213_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
if (response.ok) return response.json();
const { error, message, code } = await response.json().catch(() => ({}));
// A daily limit or a paused model won't clear within a few retries
const lasting = code === "MODEL_DISABLED" || String(error ?? "").includes("Daily limit");
if (!RETRYABLE.has(response.status) || lasting || attempt === attempts - 1) {
throw new Error(`Voice213 ${response.status}: ${message ?? error ?? response.statusText}`);
}
// Honor Retry-After when it's sent (capped), otherwise back off exponentially
const wait = Math.min(Number(response.headers.get("retry-after")) || 2 ** (attempt + 1), MAX_WAIT);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}A 429 from your key's per-day limit won't clear within a few retries: when the message says Daily limit exceeded, the helpers above stop, and you can try again the next day. A 503 with the code MODEL_DISABLED can last a while too, so they stop on it as well: send the voice-over with another model. During maintenance, Retry-After can be long, so they wait at most a minute between attempts.
Set generous timeouts
Generation time grows with the length of the text, and with the model: a short voice-over is ready in seconds, while a long script with a Pro model can take minutes. Voice213 doesn't cut long requests short, so set your client timeout in minutes, not seconds: 5 minutes covers long voice-overs.
When your client gives up
If your client cancels a request or times out, Voice213 still finishes the generation, and charges it, even though you never receive the result. A retry after a timeout can therefore cost twice, which is why generous timeouts matter more than fast retries. Until it finishes, the abandoned voice-over also counts toward your 20 requests at once, and its price stays set aside from your API credit. Voice-overs in the studio format are saved to your History in the web app, so you can still download one that finished after your client stopped waiting.
Stay within the limits
- Requests per minute and per day: 600 a minute and 1,000 a day for every key, whatever your plan.
- Requests at once: run at most 20 voice-overs at the same time. The next one gets
429until one of them ends. - Words per voice-over: up to 5,000 in either format, tone directions included.
- Tone directions: free, but they can't outweigh the spoken text. A text may be at most twice its billable characters plus 1,000 characters long, or it's refused with
400 TEXT_TOO_LONG. - Very short requests: every request is billed at least 100 characters, so batch short lines into one request when you can.
- Long scripts: split them at sentence boundaries and join the audio afterwards. Shorter requests also come back sooner:
import re
def split_text(text, limit=3000):
"""Split text into pieces of at most `limit` characters, at sentence ends."""
pieces, current = [], ""
for sentence in re.split(r"(?<=[.!?؟\n])\s+", text.strip()):
if len(current) + len(sentence) + 1 <= limit:
current = f"{current} {sentence}" if current else sentence
continue
if current:
pieces.append(current)
while len(sentence) > limit: # one sentence longer than the limit
pieces.append(sentence[:limit])
sentence = sentence[limit:]
current = sentence
if current:
pieces.append(current)
return piecesSee Rate Limits for the details.
Keep an eye on your API credit
When your API credit can't cover a request, it fails with 402 and INSUFFICIENT_CREDIT. To avoid surprises:
- Read
remainingCreditfrom every paid response, or callGET /me, and raise an alert when it drops below what a busy day costs you. - Before a big batch, price it with
POST /tts/estimate: itsenoughfield tells you whether your credit covers it. - Voice-overs you run at the same time each set their price aside from your credit while they run. A batch run in parallel can therefore get
402before any of it is charged, once the running voice-overs hold what's left. - Read prices from
GET /modelsrather than hard-coding them, so a price change reaches your own price display on its own. - Watch your balance, spend and calls on the Developer Dashboard.
- Top up before a launch or a campaign.
Save what you want to keep
The same text with the same voice, dialect and model doesn't need to be generated twice. Store the audio you get back (for example in your own object storage, keyed by a hash of the request) and serve repeat requests from there. It's faster for your users and costs nothing.
Download the files behind the URLs the API returns as soon as you get them, rather than linking to Voice213's copies forever.
Launch checklist
- The API key lives only on your server, in an environment variable or secret manager.
429,500,502and503are retried with backoff. Other errors aren't, and a daily limit or a paused model (MODEL_DISABLED) stops the retries.- Client timeouts are generous (minutes, not seconds) for long scripts and Pro models.
- Long scripts are split into shorter pieces.
- Generated audio is stored on your side.
- No code still calls a removed endpoint.
- Someone gets alerted when
remainingCreditruns low or a402comes back, and the credit is topped up.
Was this page helpful?