Circassian AI API
Translation and speech synthesis for Kabardian and Adyghe. One key, plain REST, no SDK required.
Quick start
- Get a key. There is no self-service signup — keys are issued by hand. Write to the service owner, describe what you are building and your expected volume.
- Check that the key works. One request, costs you nothing.
curl https://api.circassian.ai/v1/me \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY"
import os, httpx
r = httpx.get(
"https://api.circassian.ai/v1/me",
headers={"Authorization": f"Bearer {os.environ['CIRCASSIAN_API_KEY']}"},
)
print(r.json())
const res = await fetch("https://api.circassian.ai/v1/me", {
headers: { Authorization: `Bearer ${process.env.CIRCASSIAN_API_KEY}` },
});
console.log(await res.json());
<?php
$ch = curl_init("https://api.circassian.ai/v1/me");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("CIRCASSIAN_API_KEY")],
]);
echo curl_exec($ch);
req, _ := http.NewRequest("GET", "https://api.circassian.ai/v1/me", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("CIRCASSIAN_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
io.Copy(os.Stdout, resp.Body)
The response contains the key's name, its limits and whether the content of your requests is being stored:
{
"name": "My product",
"prefix": "ck_a1b2c3d4e5f6",
"limits": {
"requests_per_minute": 60,
"requests_per_day": 5000,
"translate_chars_per_day": 500000,
"speech_seconds_per_day": 3600
},
"content_logging": true
}API key
Every request carries the key in the Authorization header:
Authorization: Bearer ck_a1b2c3d4e5f6.LmNvbnRlbnQtc2VjcmV0LXZhbHVlA key has two parts separated by a dot: a public prefix and a secret. The secret is shown once when the key is issued and never again — store it right away.
Translate text
Languages: ru, en, tr, kbd (Kabardian), ady (Adyghe). You may omit source_lang — it will be detected — but passing it explicitly is faster.
curl https://api.circassian.ai/v1/translate \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Hello, how are you?",
"source_lang": "en",
"target_lang": "kbd"
}'
import os, httpx
API = "https://api.circassian.ai"
KEY = os.environ["CIRCASSIAN_API_KEY"]
def translate(text: str, target: str, source: str | None = None) -> str:
payload = {"text": text, "target_lang": target}
if source:
payload["source_lang"] = source
r = httpx.post(
f"{API}/v1/translate",
headers={"Authorization": f"Bearer {KEY}"},
json=payload,
timeout=60,
)
if r.status_code != 200:
raise RuntimeError(r.json()["error"]["message"])
return r.json()["text"]
print(translate("Hello, how are you?", "kbd", "en"))
const API = "https://api.circassian.ai";
const KEY = process.env.CIRCASSIAN_API_KEY;
async function translate(text, targetLang, sourceLang) {
const res = await fetch(`${API}/v1/translate`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
target_lang: targetLang,
...(sourceLang && { source_lang: sourceLang }),
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);
return data.text;
}
console.log(await translate("Hello, how are you?", "kbd", "en"));
<?php
function translate(string $text, string $target, ?string $source = null): string
{
$payload = ["text" => $text, "target_lang" => $target];
if ($source !== null) {
$payload["source_lang"] = $source;
}
$ch = curl_init("https://api.circassian.ai/v1/translate");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("CIRCASSIAN_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 60,
]);
$body = json_decode(curl_exec($ch), true);
if (isset($body["error"])) {
throw new RuntimeException($body["error"]["message"]);
}
return $body["text"];
}
echo translate("Hello, how are you?", "kbd", "en");
type translateRequest struct {
Text string `json:"text"`
SourceLang string `json:"source_lang,omitempty"`
TargetLang string `json:"target_lang"`
}
type translateResponse struct {
Text string `json:"text"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
}
func Translate(text, source, target string) (string, error) {
body, _ := json.Marshal(translateRequest{text, source, target})
req, _ := http.NewRequest("POST",
"https://api.circassian.ai/v1/translate", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CIRCASSIAN_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("api: %s", resp.Status)
}
var out translateResponse
json.NewDecoder(resp.Body).Decode(&out)
return out.Text, nil
}
Response:
{
"text": "ФӀэхъус, дауэ ущыт?",
"source_lang": "en",
"target_lang": "kbd"
}I, digit one, vertical bar — are recognised and replaced with the real palochka before the text reaches the model. Send text as your users typed it.
Batch translation
Up to 50 texts and 20,000 characters per request. Results come back in the order you sent them. One batch is noticeably faster than 50 separate calls and spends less of your per-minute limit.
curl https://api.circassian.ai/v1/translate/batch \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"texts": ["Good morning", "Thank you", "Goodbye"],
"source_lang": "en",
"target_lang": "ady"
}'
r = httpx.post(
f"{API}/v1/translate/batch",
headers={"Authorization": f"Bearer {KEY}"},
json={
"texts": ["Good morning", "Thank you", "Goodbye"],
"source_lang": "en",
"target_lang": "ady",
},
timeout=120,
)
for line in r.json()["translations"]:
print(line)
const res = await fetch(`${API}/v1/translate/batch`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
texts: ["Good morning", "Thank you", "Goodbye"],
source_lang: "en",
target_lang: "ady",
}),
});
const { translations } = await res.json();
translations.forEach((t) => console.log(t));
<?php
$payload = [
"texts" => ["Good morning", "Thank you", "Goodbye"],
"source_lang" => "en",
"target_lang" => "ady",
];
$ch = curl_init("https://api.circassian.ai/v1/translate/batch");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("CIRCASSIAN_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
$body = json_decode(curl_exec($ch), true);
foreach ($body["translations"] as $line) {
echo $line, PHP_EOL;
}
payload := map[string]any{
"texts": []string{"Good morning", "Thank you", "Goodbye"},
"source_lang": "en",
"target_lang": "ady",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST",
"https://api.circassian.ai/v1/translate/batch", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CIRCASSIAN_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, _ := (&http.Client{Timeout: 120 * time.Second}).Do(req)
defer resp.Body.Close()
var out struct {
Translations []string `json:"translations"`
}
json.NewDecoder(resp.Body).Decode(&out)
fmt.Println(out.Translations)
Dictionary
Dictionary entries for a single word. Unlike translation it does not consume your character quota — it counts as an ordinary request.
curl https://api.circassian.ai/v1/dictionary \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"word": "water", "lang": "en", "dialect": "kbd"}'Language detection
Detects the language of a text without translating it. Useful when you need to know what a user is writing in before deciding what to do with it.
curl https://api.circassian.ai/v1/detect \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Сэлам алейкум"}'
# {"language": "kbd"}Speech synthesis
Voices: male and female — the same ones used by the Telegram bot and the website. Speech languages: kbd, ady, ru, en.
"language": "tr" is rejected with an invalid_request error.
Short text (up to 1500 characters) is synthesised immediately: you get an audio/wav file, with the duration in the X-Audio-Duration header.
curl https://api.circassian.ai/v1/speech \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Сэлам, узыншэу ущыт", "language": "kbd", "voice": "female"}' \
--output speech.wav
r = httpx.post(
f"{API}/v1/speech",
headers={"Authorization": f"Bearer {KEY}"},
json={"text": "Сэлам, узыншэу ущыт", "language": "kbd", "voice": "female"},
timeout=120,
)
r.raise_for_status()
with open("speech.wav", "wb") as f:
f.write(r.content)
print("duration:", r.headers["X-Audio-Duration"], "s")
import { writeFile } from "node:fs/promises";
const res = await fetch(`${API}/v1/speech`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
text: "Сэлам, узыншэу ущыт",
language: "kbd",
voice: "female",
}),
});
if (!res.ok) throw new Error((await res.json()).error.message);
await writeFile("speech.wav", Buffer.from(await res.arrayBuffer()));
console.log("duration:", res.headers.get("X-Audio-Duration"), "s");
<?php
$payload = [
"text" => "Сэлам, узыншэу ущыт",
"language" => "kbd",
"voice" => "female",
];
$ch = curl_init("https://api.circassian.ai/v1/speech");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("CIRCASSIAN_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
CURLOPT_TIMEOUT => 120,
]);
file_put_contents("speech.wav", curl_exec($ch));
payload := map[string]string{
"text": "Сэлам, узыншэу ущыт",
"language": "kbd",
"voice": "female",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST",
"https://api.circassian.ai/v1/speech", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("CIRCASSIAN_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, _ := (&http.Client{Timeout: 120 * time.Second}).Do(req)
defer resp.Body.Close()
audio, _ := io.ReadAll(resp.Body)
os.WriteFile("speech.wav", audio, 0o644)
fmt.Println("duration:", resp.Header.Get("X-Audio-Duration"), "s")
Custom voice instead of a preset
Two additional ways to control the voice:
| Field | What it does |
|---|---|
instruct | A description of the voice in English: "calm elderly man, slow". Replaces the voice preset. |
ref_audio_url | Link to a voice sample — synthesis reproduces its timbre. The file is used during generation only and is never stored. |
speed | Speech rate from 0.2 to 2.0. Defaults to 0.85. |
Long text: jobs
Text longer than 1500 characters is not synthesised within a single request — the connection would not survive the generation time. Instead you get a job id and fetch the result separately.
Response for a long text:
{
"job_id": "5960d90303ab4626acccf69d980d1593",
"status": "queued",
"poll_url": "/v1/jobs/5960d90303ab4626acccf69d980d1593"
}Job states: queued → running → done or failed. Once the job is done the response carries an audio link and the duration. The link lives for one hour, then the file is deleted.
A ready-made client that works out for itself whether audio came back immediately or a job was created:
import os, time, httpx
API = "https://api.circassian.ai"
KEY = os.environ["CIRCASSIAN_API_KEY"]
AUTH = {"Authorization": f"Bearer {KEY}"}
def synthesize(text: str, language: str = "kbd", voice: str = "male") -> bytes:
"""Returns wav bytes regardless of the text length."""
r = httpx.post(
f"{API}/v1/speech",
headers=AUTH,
json={"text": text, "language": language, "voice": voice},
timeout=120,
)
if r.status_code != 200:
raise RuntimeError(r.json()["error"]["message"])
# Short text — audio is already here.
if r.headers["content-type"].startswith("audio/"):
return r.content
# Long text — wait for the job.
job_id = r.json()["job_id"]
while True:
time.sleep(2)
job = httpx.get(f"{API}/v1/jobs/{job_id}", headers=AUTH, timeout=30).json()
if job["status"] == "done":
audio = httpx.get(f"{API}{job['audio_url']}", headers=AUTH, timeout=60)
return audio.content
if job["status"] == "failed":
raise RuntimeError(job["error"])
with open("long.wav", "wb") as f:
f.write(synthesize("A long text…" * 200))
const API = "https://api.circassian.ai";
const auth = { Authorization: `Bearer ${process.env.CIRCASSIAN_API_KEY}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function synthesize(text, language = "kbd", voice = "male") {
const res = await fetch(`${API}/v1/speech`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ text, language, voice }),
});
if (!res.ok) throw new Error((await res.json()).error.message);
// Short text — audio is already here.
if (res.headers.get("content-type").startsWith("audio/")) {
return Buffer.from(await res.arrayBuffer());
}
// Long text — wait for the job.
const { job_id } = await res.json();
for (;;) {
await sleep(2000);
const job = await (
await fetch(`${API}/v1/jobs/${job_id}`, { headers: auth })
).json();
if (job.status === "done") {
const audio = await fetch(`${API}${job.audio_url}`, { headers: auth });
return Buffer.from(await audio.arrayBuffer());
}
if (job.status === "failed") throw new Error(job.error);
}
}
# 1. Send the long text — receive a job
JOB=$(curl -s https://api.circassian.ai/v1/speech \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "…long text…", "language": "kbd"}' \
| jq -r .job_id)
# 2. Wait until it is done
while true; do
STATUS=$(curl -s "https://api.circassian.ai/v1/jobs/$JOB" \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY")
STATE=$(echo "$STATUS" | jq -r .status)
[ "$STATE" = "done" ] && break
[ "$STATE" = "failed" ] && echo "$STATUS" | jq -r .error && exit 1
sleep 2
done
# 3. Fetch the audio
URL=$(echo "$STATUS" | jq -r .audio_url)
curl -s "https://api.circassian.ai$URL" \
-H "Authorization: Bearer $CIRCASSIAN_API_KEY" --output long.wav
Voices and languages
Fetch these lists rather than hardcoding them — they grow over time.
curl https://api.circassian.ai/v1/voices -H "Authorization: Bearer $CIRCASSIAN_API_KEY"
{
"voices": [
{"id": "male", "title": "Male"},
{"id": "female", "title": "Female"}
],
"languages": [
{"id": "kbd", "title": "Kabardian"},
{"id": "ady", "title": "Adyghe"},
{"id": "ru", "title": "Russian"},
{"id": "en", "title": "English"}
]
}Limits
Each key has four independent limits. Current values are in GET /v1/me.
| Limit | What it counts | What you get when exceeded |
|---|---|---|
| Requests per minute | Any calls, failed ones included | rate_limited with a Retry-After: 60 header |
| Requests per day | The same, per UTC calendar day | quota_exceeded |
| Translation characters per day | Input plus output characters | quota_exceeded |
| Speech seconds per day | Duration of the audio produced | quota_exceeded |
The number of concurrent calls into the models is capped separately. When no slot frees up in time you get queue_timeout — the connection is not dropped, you can simply retry. Speech runs one task at a time, so during busy periods long texts are better sent as jobs than waited on.
Errors
Every error has the same shape — branch on error.type, not on the message text:
{
"error": {
"type": "quota_exceeded",
"message": "daily quota of 500000 characters is spent (499880 used), it resets in about 7h"
}
}type, not on the message.
The type field is stable and machine-readable. The message is written for a developer reading logs and is not meant to be shown to your end users as-is.
| Type | Status | What to do |
|---|---|---|
unauthorized | 401 | Check the key. It may have been revoked — request a new one |
invalid_request | 422 | Fix the request: language, length, fields. Retrying will not help |
rate_limited | 429 | Wait Retry-After seconds, then retry |
quota_exceeded | 429 | Daily limit is spent. Retrying before it resets is pointless |
queue_timeout | 503 | The model is busy. Retry in a few seconds |
upstream_unavailable | 503, 504 | The model is down. Retry with backoff |
upstream_error | 502 | Model-side failure. Retry once, then report it |
not_found | 404 | No such job or file, or the link has expired |
internal_error | 500 | Service error. Retry later and report if it persists |
Retry with backoff applies to 429, 502, 503 and 504 only. Other 4xx responses will never succeed on retry — the request itself has to change.
import time, httpx
RETRIABLE = {429, 502, 503, 504}
def request_with_retry(method: str, url: str, attempts: int = 4, **kwargs):
for attempt in range(attempts):
r = httpx.request(method, url, **kwargs)
if r.status_code not in RETRIABLE:
return r
# The service tells you when to come back — listen to it.
pause = int(r.headers.get("Retry-After", 2 ** attempt))
if r.json().get("error", {}).get("type") == "quota_exceeded":
break # daily quota: wait for tomorrow, do not hammer
time.sleep(pause)
return r
const RETRIABLE = new Set([429, 502, 503, 504]);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function requestWithRetry(url, options, attempts = 4) {
let res;
for (let attempt = 0; attempt < attempts; attempt++) {
res = await fetch(url, options);
if (!RETRIABLE.has(res.status)) return res;
const body = await res.clone().json();
// Daily quota: wait for tomorrow, do not hammer.
if (body.error?.type === "quota_exceeded") break;
const pause = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await sleep(pause * 1000);
}
return res;
}
Data
Recorded for every call, always: timestamp, key, operation, model, volume in characters or audio seconds, success or error type, and the calling address. This is what usage accounting and incident triage rely on.
Request and response content — your source text and the result — is kept for 30 days and then deleted automatically. It exists so that complaints about translation quality and failures can be investigated.
Content logging can be turned off per key: ask the owner and texts sent with your key will stop being stored. Usage accounting keeps working — limits and counters do not depend on the logs.
Reference audio for voice cloning is never stored: it is used during generation and only the fact that it was used remains in the log. Generated files are deleted when their link expires.
All operations
| Method | Path | Purpose | Key |
|---|---|---|---|
POST | /v1/translate | Translate a text | required |
POST | /v1/translate/batch | Translate a batch of texts | required |
POST | /v1/dictionary | Dictionary entries for a word | required |
POST | /v1/detect | Detect the language | required |
POST | /v1/speech | Synthesise speech | required |
GET | /v1/jobs/{job_id} | Job status | required |
GET | /v1/audio/{artifact_id} | Generated audio | required |
GET | /v1/voices | Voices and speech languages | required |
GET | /v1/languages | Translation languages | required |
GET | /v1/me | Key details and limits | required |
GET | /health | Service status | not required |
GET | /openapi.json | OpenAPI schema | not required |
The OpenAPI schema is good for generating a client in your language.