Circassian AI API

Translation and speech synthesis for Kabardian and Adyghe. One key, plain REST, no SDK required.

Quick start

  1. 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.
  2. Check that the key works. One request, costs you nothing.
curl https://api.circassian.ai/v1/me \
  -H "Authorization: Bearer $CIRCASSIAN_API_KEY"

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.LmNvbnRlbnQtc2VjcmV0LXZhbHVl

A 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.

The key is access to your usage account. Keep it on your server, not in mobile app code or browser JavaScript — anyone can extract it from there. If a key leaks, tell the owner: revocation takes effect immediately and you get a new one.

Translate text

POST /v1/translate

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"
  }'

Response:

{
  "text": "ФӀэхъус, дауэ ущыт?",
  "source_lang": "en",
  "target_lang": "kbd"
}
Palochka is normalised for you. The characters people substitute for «Ӏ» — Latin 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

POST /v1/translate/batch

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"
  }'

Dictionary

POST /v1/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

POST /v1/detect

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

POST /v1/speech

Voices: male and female — the same ones used by the Telegram bot and the website. Speech languages: kbd, ady, ru, en.

Turkish is available for translation but not for speech. A request with "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

Custom voice instead of a preset

Two additional ways to control the voice:

FieldWhat it does
instructA description of the voice in English: "calm elderly man, slow". Replaces the voice preset.
ref_audio_urlLink to a voice sample — synthesis reproduces its timbre. The file is used during generation only and is never stored.
speedSpeech 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.

GET /v1/jobs/{job_id}
GET /v1/audio/{artifact_id}

Response for a long text:

{
  "job_id": "5960d90303ab4626acccf69d980d1593",
  "status": "queued",
  "poll_url": "/v1/jobs/5960d90303ab4626acccf69d980d1593"
}

Job states: queuedrunningdone 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))
Poll no more than once every two seconds. Every status call spends your per-minute limit. Generating one second of speech takes roughly a tenth of a second, so a 5000-character text is typically ready in about a minute.

Voices and languages

GET /v1/voices
GET /v1/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.

LimitWhat it countsWhat you get when exceeded
Requests per minuteAny calls, failed ones includedrate_limited with a Retry-After: 60 header
Requests per dayThe same, per UTC calendar dayquota_exceeded
Translation characters per dayInput plus output charactersquota_exceeded
Speech seconds per dayDuration of the audio producedquota_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"
  }
}
Branch on 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.
TypeStatusWhat to do
unauthorized401Check the key. It may have been revoked — request a new one
invalid_request422Fix the request: language, length, fields. Retrying will not help
rate_limited429Wait Retry-After seconds, then retry
quota_exceeded429Daily limit is spent. Retrying before it resets is pointless
queue_timeout503The model is busy. Retry in a few seconds
upstream_unavailable503, 504The model is down. Retry with backoff
upstream_error502Model-side failure. Retry once, then report it
not_found404No such job or file, or the link has expired
internal_error500Service 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

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

MethodPathPurposeKey
POST/v1/translateTranslate a textrequired
POST/v1/translate/batchTranslate a batch of textsrequired
POST/v1/dictionaryDictionary entries for a wordrequired
POST/v1/detectDetect the languagerequired
POST/v1/speechSynthesise speechrequired
GET/v1/jobs/{job_id}Job statusrequired
GET/v1/audio/{artifact_id}Generated audiorequired
GET/v1/voicesVoices and speech languagesrequired
GET/v1/languagesTranslation languagesrequired
GET/v1/meKey details and limitsrequired
GET/healthService statusnot required
GET/openapi.jsonOpenAPI schemanot required

The OpenAPI schema is good for generating a client in your language.