Documentation

The Baarcha API

Chat, transcription and speech for Tunisian Arabic, behind one small HTTP API. Keys live in the dashboard; the chat, transcription and speech samples below each have a tab in the playground too. The playground has those three modes and no more: cloned voices are created and deleted in the dashboard, and the models list is a plain GET.

#Introduction

Speech and language models for Tunisian Arabic, behind one small HTTP API.

Baarcha trains models for Tunisian Arabic, called Derja, ISO 639-3 aeb. Thirteen million people speak it and almost nobody serves it natively. The models read Arabic script, Tunizi (Latin letters with digits, 3aslema-style), and the French code-switching that happens mid-sentence in any real Tunisian conversation. Write Tunizi and you are understood; what comes back is Arabic-script Derja.

The API is deliberately small: three models, six endpoints, OpenAI-compatible request and response shapes wherever that makes sense. Existing client libraries, including the official openai packages, work by changing base_url and the key.

Base URL
https://baarcha.tn/api/v1
EndpointWhat it does
GET /modelsList the models, their capabilities and their status.
POST /chat/completionsChat with Hannibal in Derja, streaming or not.
POST /audio/transcriptionsTunisian speech in, text out.
POST /audio/speechText in, a Tunisian voice out: Dido, built-in voices or your own.
GET POST /voicesThe voices this key may speak with, and how to clone one. See Voices.
GET DELETE /voices/{voice_id}Read one cloned voice, or delete it and its reference audio.

Keys come from the developer dashboard; every request carries one. If you would rather click before you read, the playground runs all three capabilities in the browser and shows the exact request behind every run.

#Live models and demo mode

A deployment of this site runs in one of two modes, and it will tell you which: GET /api/health reports "mode": "live" or "mode": "fixtures", and every surface that shows model output carries a Demo badge in the second case: the home page console, the studio on Dido's page, the chat and the playground. Each badge sits next to a sentence saying what it means.

GET /api/health
{
  "connected": true,
  "chat": true,
  "transcription": true,
  "speech": true,
  "mode": "live"
}

One boolean per capability: chat, transcription and speech. Each reports whether that model is answering right now; they run on GPUs that get taken for training. connected is false when no models are attached at all, and the three endpoints that need one then answer 503 model_not_available. The rest of the API keeps working as far as it can without a model: GET /api/v1/models and GET /api/v1/voices are answered from this server and still return 200, while creating a cloned voice reaches the voice service and fails there with a 502, and deleting one retires it here and reports 502 delete_unconfirmed because nothing upstream could confirm the audio is gone. These five fields are the whole response.

  • Live: requests reach the real models. All three answer: hannibal on chat, fennec on transcription, dido on speech. A 503 from any of them is a gateway that is down or a model that is cold, not a model that has not shipped.
  • Demo (fixtures): no GPU is attached, so the API answers from a small fixed corpus in-process: Derja replies recorded from Hannibal itself, and the twelve clips Dido really rendered with their true transcripts. Model gating is off, so a model listed as in_training would be served here too. Chat answers 200 for anything you send it; speech and transcription answer only for the twelve, as below.
  • Speech in demo mode is the exception. /api/v1/audio/speech will only return audio for one of the twelve sentences Dido actually rendered. Those are the transcripts listed on Dido's page, matched on the words rather than the bytes: spacing is collapsed, harakat and quotation marks are dropped, and punctuation at the end is trimmed, so a stray final full stop will not lose you the render. Punctuation inside the sentence is part of the key. A comma you added mid-sentence is a different sentence as far as the lookup is concerned. Any other input is declined with a 422 and the code fixture_no_render. It does not hand back a recording of a different sentence: fluent Derja in a natural voice is indistinguishable by ear from a working model, so substituting one would misrepresent the product to the person least able to check.
  • Transcription refuses on the same terms. /api/v1/audio/transcriptions recognises those same twelve clips and returns each one's true transcript. It recognises them by byte length, which is a fingerprint rather than a comparison. No two of the twelve are the same size, so a shipped file is always identified correctly; the caveat is the other direction, since any upload that happens to be exactly one of those twelve lengths gets that clip's transcript back. Worth knowing before you read a demo-mode transcript as a verdict on your own audio. Any other audio, including a recording of your own voice, is declined with a 422 and the code fixture_no_transcript. It does not guess: a plausible Derja sentence returned for words you never said is a lie you have no way to catch, and it reads exactly like a working model.
  • Cloned voices are created for real and cannot be spoken. Everything under Voices works in demo mode: a voice is validated, stored against your account, comes back ready, appears in every picker, and deletes for good, samples included. The one step that does not is synthesis: /api/v1/audio/speech with a cloned voice is declined with a 422 and the code fixture_no_clone, because the only audio a modelless deployment could return is one of Dido's built-in recordings, and passing that off as somebody's own cloned voice would be the same substitution as the two above, aimed at the one capability here that can be used against a person.
  • Chat parameters mean different things in the two modes, and this is the difference. Live, system is a real system turn and temperature is real sampling. The model gets both. In demo mode there is no model to instruct or to sample, so each does one small, real thing instead: a system prompt naming a register, French or Arabic script, selects the reply the corpus recorded in that register, and temperature decides how far the pick may wander through the pool of recorded fallbacks (0 is always the same reply for a given message). That is selection inside a fixed corpus: not instruction-following, and not sampling.

Every latency figure the playground prints is a real measurement of a real request. In demo mode the thing on the other end is the fixture layer, so what is being timed is its scripted pacing: an authored 250–600 ms before the first chat chunk, 30–60 ms between them, a table lookup for a transcript, an already-rendered clip read off disk. Those readouts are labelled Demo pacing, not model latency for exactly that reason. Screenshot them as model numbers and they will be wrong in both directions.

#Authentication

One bearer key per request. Keys are shown once and stored hashed.

Create keys in the dashboard. One Google sign-in, no card, no forms. A key looks like pk_live_ followed by 24 characters, and the full value is shown exactly once, at creation. After that the server keeps only its SHA-256, so there is no way to see a key again, only to revoke it and mint another.

Send the key on every request as a bearer token:

The header
Authorization: Bearer pk_live_…

# most tools read it from the environment:
export BAARCHA_API_KEY="pk_live_…"

A missing key answers 401 with code missing_api_key; a wrong or revoked key answers 401 with invalid_api_key. Revoked and never-existed are deliberately indistinguishable: a dead key should not remain a way to probe that it once lived.

#Keeping it safe

  • Call the API from a server, never from a browser or a mobile app. A key that ships to clients is public the moment it ships.
  • Keep the key in an environment variable or a secret store, out of source control.
  • One key per app or environment, up to 10 live keys per account, so revoking one does not take the others down. Keys separate your apps; they do not add budget, and rate limits are counted per account.
  • If a key leaks, revoke it immediately and create a new one. Revocation is instant; the next request with the old key gets 401.

#Quickstart

A key, one request, and a streamed reply in Derja.

Grab a key from the dashboard, export it, and ask Hannibal something. The openai client libraries work as-is: point base_url at https://baarcha.tn/api/v1.

Install
# curl is all you need. No SDK, no install.
export BAARCHA_API_KEY="pk_live_…"
Your first chat completion
curl https://baarcha.tn/api/v1/chat/completions \
  -H "Authorization: Bearer $BAARCHA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "hannibal",
    "messages": [
      {"role": "user", "content": "عسلامة، شنية أحوالك اليوم؟"}
    ]
  }'
The response
{
  "id": "chatcmpl-d09da7e40dd04b4599049625",
  "object": "chat.completion",
  "created": 1787142412,
  "model": "hannibal",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "الحمد لله لاباس، و إنتي؟"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 8,
    "completion_tokens": 11,
    "total_tokens": 19
  }
}

#Streaming

Pass "stream": true and the reply arrives as server-sent events, one chat.completion.chunk per token, ending with data: [DONE]. The chunk format is specified in the chat reference.

The same request, streamed
curl -N https://baarcha.tn/api/v1/chat/completions \
  -H "Authorization: Bearer $BAARCHA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "hannibal",
    "messages": [{"role": "user", "content": "chnwa a7welek?"}],
    "stream": true
  }'

From here: the models and what each one serves, the full chat, transcription and speech references, and what every error looks like.

#Models

Three models, one family. The table below is generated from the same registry the API serves.

ModelServesStatusNotes
hannibalPOST /chat/completionsavailableA 27B model in the Barca H1 family. Chat, translation, diacritisation and style imitation, in Derja.
fennecPOST /audio/transcriptionsavailableDerja speech recognition: Tunisian, and aware of the French it is code-switched with.
didoPOST /audio/speechavailableDerja speech synthesis, with zero-shot voice cloning from one short recording.

hannibal is a 27B model in the Barca H1 family, and it does four things in Derja: chat, translation, diacritisation and style imitation. It decodes at about 111 tokens a second on a single stream, which is what you need when you are deciding whether to stream a reply or wait for it. fennec is the Derja recogniser, code-switch aware, and the model behind transcriptions. dido is Derja text-to-speech, with zero-shot cloning from one short recording. See Voices.

GET /api/v1/models returns the same catalogue, OpenAI-shaped. Models still in training are listed with "status": "in_training" rather than hidden, because a developer deciding what to build against deserves to see what is coming. They decline requests with a 503 `model_not_available` until they ship. The exception is a deployment in demo mode, where they answer from the fixture corpus while still reporting in_training here.

List models
curl https://baarcha.tn/api/v1/models \
  -H "Authorization: Bearer $BAARCHA_API_KEY"
Response (abridged)
{
  "object": "list",
  "data": [
    {
      "id": "hannibal",
      "object": "model",
      "created": 1751328000,
      "owned_by": "baarcha",
      "capabilities": [
        "chat"
      ],
      "status": "available"
    },
    {
      "id": "dido",
      "object": "model",
      "created": 1754870400,
      "owned_by": "baarcha",
      "capabilities": [
        "speech"
      ],
      "status": "available"
    }
  ]
}

#Chat completions

Derja in, Derja out. Arabic script, Tunizi and French code-switching all understood.

POST/api/v1/chat/completions

Creates a model response for a conversation. The request and response shapes are OpenAI's, so the openai client libraries work unchanged; the shapes are also small enough to speak with fetch and twenty lines.

Request body

modelstringrequired
A model with the chat capability. Today that is hannibal.
messagesarrayrequired
The conversation so far: objects with role (system, user or assistant) and content. Content is either a string, the empty string included, or an array of {type: "text", text} parts, which are concatenated; any other part type is refused rather than mangled. At least one user message is required. Two caps, both 413 request_too_large: at most 2,048 messages, and at most 32,000 characters across all of them. Only the most recent 24 turns before the last user message are kept as context.
streambooleanoptional
Default false. When true, the response is server-sent events, described below. Must be a real boolean; the string "true" is a 400 rather than a silent non-streaming response.
stream_optionsobjectoptional
Only legal alongside stream: true. The one option is include_usage (boolean): when true, one extra chunk arrives before [DONE] carrying the usage totals. See streaming chunks.
max_tokensintegeroptional
Optional cap on the reply length, counted in streamed deltas (roughly one word each). When the cap cuts the reply short, finish_reason is "length".
temperaturenumberoptional
Sampling temperature, validated 0–2 and applied: the gateway maps it straight onto the model's own sampling, so low values keep the reply close to the likeliest wording and high ones let it wander. Default is the model's. On a deployment in demo mode there is no model to sample and the value does one narrower, real thing instead. It is the fixture corpus's only variation control, deciding how far the pick may wander through the pool of recorded fallback replies, with 0 always giving the same one for a given message. Selection inside recorded text, in other words, rather than sampling.

#Response

Without stream, one chat.completion object: choices[0].message holds the reply and finish_reason is the model's own: "stop" when it finished, "length" when max_tokens cut it off. usage carries the model's real prompt_tokens and completion_tokens, counted by the thing that did the work rather than approximated from character counts here, so code that bills or budgets on tokens can use them directly.

#Streaming chunks

With "stream": true the body is text/event-stream. The first chunk carries delta: {"role": "assistant", "content": ""}, each following chunk a delta.content fragment, and the final chunk an empty delta with the finish_reason. The stream always ends with the literal line data: [DONE]. The model decodes at about 111 tokens a second on a single stream, so a paragraph-length reply is a second or two of streaming, long enough that streaming is worth the trouble for anything a person is waiting on.

The wire, verbatim
data: {"id":"chatcmpl-349dbd0818b048e7b62e0a1a","object":"chat.completion.chunk","created":1787142423,"model":"hannibal","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

data: {"id":"chatcmpl-349dbd0818b048e7b62e0a1a","object":"chat.completion.chunk","created":1787142423,"model":"hannibal","choices":[{"index":0,"delta":{"content":"عسلامة! "},"finish_reason":null}]}

data: {"id":"chatcmpl-349dbd0818b048e7b62e0a1a","object":"chat.completion.chunk","created":1787142423,"model":"hannibal","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Buffer on the blank line between events, not on chunk boundaries. An event can straddle two network reads, and splitting each read on its own is the bug that only shows up over a real network. Skip the [DONE] sentinel before JSON.parse. The quickstart's JavaScript sample does both.

#Usage while streaming

A streamed response carries no usage by default. Ask for it with "stream_options": {"include_usage": true} and one extra chunk arrives after the finish_reason chunk and before [DONE]:

The usage chunk
data: {"id":"chatcmpl-19b4c15b4c3241b7b48cf662","object":"chat.completion.chunk","created":1787147518,"model":"hannibal","choices":[],"usage":{"prompt_tokens":2,"completion_tokens":13,"total_tokens":15}}

data: [DONE]
  • stream_options with stream absent or false is a 400. The option would have nothing to attach to.
  • stream_options that is not an object (a string, an array, null) is a 400.
  • stream_options.include_usage that is not a boolean is a 400. Omitting it entirely is fine and means false.

#Code-switching and script

  • Write to the model however a Tunisian would write: Arabic script (عسلامة), Tunizi (chnwa a7welek?) or French. Mixing mid-sentence is normal input, not an edge case.
  • Replies come back in Arabic script, with French where a speaker would actually switch. Do not "clean" the mix out; it is the register.
  • Reading a script and writing it are different jobs, and this is where they part. Send Tunizi and you are understood, and that is measured. The reply is still Arabic-script Derja, because Tunizi is not a register the model writes reliably and we would rather say so here than have you find out in production. There is no language parameter to change that.

#Rendering Derja (RTL) correctly

  • Set direction per message, never per document: unicode-bidi: plaintext (or dir="auto" on the element) lets each paragraph pick its own direction, which is the only correct behaviour for code-switched text.
  • Never force direction: rtl on a whole chat surface. A French reply, or the Tunizi a visitor just typed into it, would render backwards-punctuated.
  • Inside code or log output, keep the container direction: ltr and wrap Arabic string values in <bdi> or Unicode isolates (U+2066–U+2069) so quotes and commas stay put. This page's own samples do exactly that.
  • For Arabic body text, a Naskh face at a generous line-height (~1.9) is the difference between legible and squinting.

#Transcriptions

Tunisian speech in, Tunisian text out.

POST/api/v1/audio/transcriptions

Transcribes spoken Derja. Send multipart/form-data. It is the one endpoint that is not JSON, because the audio itself is the request.

Form fields

filefilerequired
The audio, up to 8 MiB (413 request_too_large beyond, 400 for an empty file). Any container, any sample rate. The model resamples what it is given, so there is nothing to convert before you upload. The filename's extension is still read as the container hint, so name the field honestly (.mp3, .wav, .webm, .m4a, .ogg); an extension-less upload is treated as .webm.
modelstringrequired
A model with the transcription capability. That is fennec, which is the model this endpoint runs on. hannibal served here while Fennec was training and no longer does: it is a chat model, and a request naming it is a 400 model_not_supported rather than Fennec's answer under another name.
response_formatstringoptional
json (default) or verbose_json. Anything else is a 400.
Transcribe a clip
curl https://baarcha.tn/api/v1/audio/transcriptions \
  -H "Authorization: Bearer $BAARCHA_API_KEY" \
  -F file=@recording.mp3 \
  -F model=fennec
Response
{ "text": "شنوا رايك نعملو طلة على السوق الجديد؟" }

With response_format=verbose_json, two fields join the text:

verbose_json response
{
  "task": "transcribe",
  "language": "aeb",
  "text": "الشارع مسكر اليوم على خاطر الأشغال، لازم نعملو دورة طويلة باش نوصلو للخدمة في الوقت."
}

language is always aeb, which is ISO 639-3 for Tunisian Arabic and the one language this model hears. Fields the endpoint does not return (duration, word timestamps) are not listed here because they do not exist yet; when they do, this page changes the same day.

Transcripts come back in Arabic script with any code-switched French kept in Latin script, exactly as spoken. Read the rendering notes before you drop them into an LTR interface.

#Reading a transcript

  • 3 is the letter ع
  • 7 is the letter ح
  • 9 is the letter ق

#Usage: duration, not tokens

Transcription bills by how much audio it listened to. There is no token in this operation to count, so rather than invent one, usage names its own unit:

usage on a transcription response
{ "usage": { "type": "duration", "seconds": 5 } }

type is there so a client that handles more than one modality can branch on the unit instead of guessing from which fields are present. Chat answers in prompt_tokens and completion_tokens, speech in headers, and this in seconds of audio.

#Speech

Text in, a Tunisian voice out. Built-in voices at 24 kHz, or one of your own.

POST/api/v1/audio/speech

Renders Derja text as audio with Dido, a Tunisian voice rather than a Tunisian accent on someone else's. The response body is the audio bytes.

Request body

modelstringrequired
Always dido.
inputstringrequired
The text to speak, at most 5,000 characters (413 request_too_large beyond). That cap is measured rather than cautious: the model has no length limit of its own and quality holds all the way to it, with mean opinion scores of 3.40–3.63 across the range and nothing failing. Arabic script, Tunizi and French code-switching are all fair game: réunion mid-sentence comes out French.
voicestringoptional
A built-in voice name, or the id of a cloned voice belonging to this key's account, which looks like vc_97e9a9dc0da8bd91. Which built-ins exist is the model's to decide and has changed. Read GET /api/v1/voices for the current set rather than hardcoding names, and the voices array on dido in GET /api/v1/models says the same thing. See Voices for creating one, and for what happens when a clone is still building or is not yours.
response_formatstringoptional
wav, mp3 or opus. All three are real: wav is what the model produces, at 24 kHz, mono, 16-bit PCM, and the other two are transcoded from it server-side. The response's Content-Type (audio/wav, audio/mpeg, audio/opus) is the truth about what you received; read the header rather than assuming the extension.
Render a sentence
curl https://baarcha.tn/api/v1/audio/speech \
  -H "Authorization: Bearer $BAARCHA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "dido",
    "input": "أمي ديما تقعد تحكي على طفولتها و تحسها ما زالت صغيرة عندي.",
    "voice": "female"
  }' \
  --output speech.mp3
0:00 / 0:00
What it sounds like: Dido's female voice rendering the sentence in the sample above, «أمي ديما تقعد تحكي على طفولتها و تحسها ما زالت صغيرة عندي.» (My mother always talks about her childhood, and she still feels like a little girl to me). 24 kHz mono.

#Predicting length and wait

Two constants, both measured, both linear in the length of input. They are worth wiring into a client rather than discovering: one tells you how much audio you are about to get, the other how long you will be waiting for it.

From characters, everything else
audio_seconds      ≈ chars × 0.088
generation_seconds ≈ chars ÷ 86

Synthesis runs roughly eight times faster than the audio plays, so most requests come back quickly. The far end of the range is the one to plan for: a full 5,000-character request is about 7 minutes of audio and about 58 seconds of waiting.

#What a render costs

Speech has no token to count, so usage arrives in headers rather than in a body this endpoint does not have, since the body is audio. X-Usage-Characters is what you sent; X-Usage-Audio-Seconds is what you got back, and audio seconds are the billing unit. Both are on the response next to the x-ratelimit-* headers.

Response headers (abridged)
Content-Type: audio/wav
X-Usage-Characters: 61
X-Usage-Audio-Seconds: 5

#Voices

The built-in voices, plus any you clone from recordings you have the right to use.

voice on the speech endpoint takes either a built-in name or a cloned voice id. There is no separate synthesis endpoint for clones and no second catalogue to consult: one list, one parameter, and kind on each row when the difference matters.

#List voices

GET/api/v1/voices

The built-ins and this key's own cloned voices, in one list. It is also the answer to "which built-in voices are there", which is the model's to decide and has changed before. Read it rather than hardcoding names. Cloned voices are scoped to the account the key belongs to: a key never sees another account's voices, in this list or anywhere else.

Response
{
  "object": "list",
  "data": [
    {
      "id": "female",
      "object": "voice",
      "name": "Female",
      "kind": "builtin",
      "status": "ready",
      "created": null,
      "preview_url": null
    },
    {
      "id": "vc_97e9a9dc0da8bd91",
      "object": "voice",
      "name": "صوت عليّ",
      "kind": "cloned",
      "status": "ready",
      "created": 1787157437,
      "preview_url": null
    }
  ]
}

#Create a voice

POST/api/v1/voices

multipart/form-data, not JSON, because the request carries audio. The build is synchronous: it takes a second or two and the response carries the finished voice, "status": "ready", usable on the speech endpoint the moment you have it. There is nothing to poll and no job id to keep.

Request body (multipart)

namestringrequired
What you will recognise it by, up to 60 characters. Longer is a 400 name_too_long, never a silent trim. Arabic script is fine, and counts by character rather than by byte; the dashboard renders voice names with their own text direction.
samplefilerequired
The audio the voice is built from: one recording. Cloning is zero-shot: one clip is what the model wants, and extra clips are not concatenated into a longer reference. Attaching a second is a 400 too_many_samples rather than a silent choice between them. Under 10 MB. Accepted: wav, mp3, m4a, webm, ogg, flac.
consentstringrequired
true. Your affirmation that you have the right to clone the voice in this recording. Anything else, including omitting it, is a 400 with the code consent_required, and nothing is forwarded.
  • The hard limits: at least 3 seconds, capped at 30 seconds.
  • The recommendation, which is a different thing: ask for 10–15 seconds of continuous speech. That is the window the voices kept by ear came out of. It is practice, not a threshold. Quality is flat across the accepted range and the duration measurements behind it are noisy, so do not read the window as a quality curve and do not assume longer is better. It is just longer.
  • Over the cap is trimmed, not refused. A clip longer than the maximum is cut down to it and the voice is built from what fits, so the end of a long recording simply does not contribute. Record to the window rather than relying on the trim to choose well.
  • One speaker, and nobody else audible, including a television.
  • Somewhere quiet. Background noise is learned along with the voice.
  • Up to 5 cloned voices per account; a create beyond that is a 409 voice_limit.
  • The container is read from the part's content type when that names an audio format, and from the file's extension otherwise. Nothing special is needed from curl, openAsBlob or requests, all of which upload as application/octet-stream or text/plain: -F "sample=@take-one.wav" is enough, and ;type=audio/wav is not required.
Create a voice
curl https://baarcha.tn/api/v1/voices \
  -H "Authorization: Bearer $BAARCHA_API_KEY" \
  -F "name=صوتي" \
  -F "consent=true" \
  -F "sample=@take-one.wav"

#Get one voice

GET/api/v1/voices/{voice_id}

One voice, by id. status is ready, failed or processing; a failed voice carries an error naming the reason and cannot be used. Since create answers ready, this is a read rather than a wait. It is how you check a voice still exists, and the endpoint a client that branches on status would poll if building ever became asynchronous. A voice that is not on this account answers 404 voice_not_found, the same body a voice id that never existed gets. That is deliberate, so this endpoint cannot be used to discover which ids are real.

Read one voice
curl https://baarcha.tn/api/v1/voices/vc_97e9a9dc0da8bd91 \
  -H "Authorization: Bearer $BAARCHA_API_KEY"

#Delete a voice

DELETE/api/v1/voices/{voice_id}

Synchronous and destructive. The voice and the reference audio you uploaded are destroyed before the response is written. Not queued, not marked for deletion, not soft-deleted with a flag. When the call returns successfully the recording is gone, there is nothing to restore from, and the voice has already left the list and stopped working on the speech endpoint.

The one thing that is not flat is the failure path. If the voice service cannot confirm the destruction, the answer is a 502 delete_unconfirmed rather than a success: the voice is already unusable at that point, but what has not been confirmed is the destruction of the recording, and saying "deleted" without it would be a promise about someone else's disk. The delete is retried automatically, and calling DELETE again forces another attempt.

Deleting a built-in voice is a 400 builtin_not_deletable. They ship with the model and belong to no account.

#Speak with a cloned voice

Pass the id as voice on POST /api/v1/audio/speech. Everything else about that endpoint is unchanged.

Render with a clone
curl https://baarcha.tn/api/v1/audio/speech \
  -H "Authorization: Bearer $BAARCHA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "dido",
    "input": "شنوا رايك نعملو طلة على السوق الجديد؟",
    "voice": "vc_97e9a9dc0da8bd91"
  }' \
  --output speech.mp3
If the voice is…Answer
still building409 voice_not_ready. The voice is fine, it is not finished. You should not meet this today, since create answers ready; it stays in the contract so that an asynchronous build later is not a breaking change. Handle it by re-reading the get endpoint rather than by treating it as a failure.
failed400 voice_failed. It cannot be used. Delete it and create another from better samples.
not on this account400 voice_not_found, the same answer a typo gets. Another account's voice id is not usable and not distinguishable from a made-up one.

#Why a create is refused

Two kinds of refusal, in one envelope. The first two below are the voice service's judgement about the audio, and only something that can hear can make them. The rest are decided here before a byte is forwarded.

`code`StatusWhat to do
sample_too_short400The clip is shorter than the minimum. Record at least 3 seconds of continuous speech, which is two or three full sentences at a normal pace.
sample_too_noisy400The recording did not come through as speech, which is broader than its name. The gate upstream scores how clean the audio is and then requires a real transcript from it, so a clip fails this either by being noisy or by being pristine and empty. Near-silence and music both score well on cleanliness and then produce nothing to align. Somewhere quieter, microphone closer, and keep talking: it has to be clean and have words in it.
multiple_speakers400More than one person is audible. Specified, not yet enforced. The detector exists but is not in the upload path, so a create will not currently be refused for this. Record alone anyway: a clone built from two voices is built from two voices.
unsupported_format400Neither the part's content type nor the file's extension names a container this API reads. Send wav, mp3, m4a, webm, ogg, flac, with a matching extension on the filename, which is what an upload from curl or requests is identified by.
sample_too_large400 / 413The sample is over 10 MB.
too_many_samples400Two or more sample files were attached; the limit is 1. Refused rather than quietly reduced to the first one, for the same reason name_too_long is refused rather than trimmed: the voice service would use at most one of them, and a caller who sent three would be told they had built a voice from three. Send your best take.
voice_limit409This account already holds 5 voices. Delete one first.
consent_required400consent=true was missing. Nothing was forwarded to the voice service.
name_required400name was empty.
name_too_long400name is over 60 characters. It is refused rather than cut to fit: a voice returned under a name you did not choose is the kind of success nobody asked for.
sample_required400No sample file was attached.

#Voices in demo mode

On a deployment in demo mode the whole of this section works except the last step. A voice is really created, really validated, really stored against your account, really comes back ready, really appears in every picker on the site, and really deletes, samples and all, in demo mode too. What demo mode cannot do is speak: there is no voice model attached, so POST /api/v1/audio/speech with a cloned voice answers 422 with the code fixture_no_clone.

preview_url is null on every voice, in every mode. Reserved, not pending: it is in the response shape because the contract has the field and removing it would break a client that reads it, and nothing is scheduled to start filling it in. In demo mode it could not be filled in honestly anyway. A preview is a rendering, and the only file a modelless deployment could point at would be somebody else's voice. Read it as a field that will be null, and do not build a player that waits for it.

Two of the rejections above, sample_too_noisy and multiple_speakers, are judgements about what audio sounds like, and demo mode cannot listen. They are reachable there for review by putting noisy or two-speakers in a sample's file name, and the response carries "simulated": true and says in its own message that the rejection was rehearsed rather than heard. Note that multiple_speakers is rehearsed in demo mode and not enforced live either. The detector is not in the upload path yet, so today it is a code in the contract rather than an answer anyone has received.

consent_required, voice_limit, name_too_long and sample_too_large are real checks in both modes: the affirmation is read before any audio is forwarded, the per-account cap is counted in the database, the name is counted in code points, and the sample sizes are counted in bytes. unsupported_format is real too, with one honest qualification: it reads the container label, the part's content type or the file's extension, not the bytes inside it. A .wav full of something else gets past this server and is the gateway's to refuse.

#Errors

One envelope, five types, and every code the API can actually return.

Every error is JSON in OpenAI's envelope. message is a human sentence, type is the family to branch on, code is the precise machine reason (sometimes null for simple field validation, where param already says everything), and param names the offending field when there is one.

The envelope
{
  "error": {
    "message": "The model 'gpt-4' does not exist. List models at GET /api/v1/models.",
    "type": "invalid_request_error",
    "code": "model_not_found",
    "param": "model"
  }
}

Branch on type for policy (retry, re-auth, fix the request) and on code for precision. Never show code to an end user raw. message is the readable one.

#Types

TypeStatusMeaning
invalid_request_error400 / 404 / 405 / 409 / 413 / 422The request itself is wrong for this deployment. Fix it; retrying unchanged cannot help. The 422s are the three demo-mode refusals below, and the 409s are the two states you wait out rather than fix.
authentication_error401No key, or a key the server does not know. Check the dashboard.
rate_limit_error429Too many requests on this account this minute. The budget is the account's, not the key's, so another key does not raise it. Wait retry-after seconds; see rate limits.
model_not_available503A real model that is not serving right now: still in training, not wired to a gateway from this deployment, or cold, which the code row below explains. Nothing is faked in its place.
server_error502The gateway failed or was unreachable. Safe to retry with backoff.

#Every code

CodeTypeStatusWhen
missing_api_keyauthentication_error401No Authorization: Bearer … header.
invalid_api_keyauthentication_error401Unknown or revoked key. The two answer identically, on purpose.
rate_limit_exceededrate_limit_error429The account's sliding window is full. Carries retry-after.
invalid_jsoninvalid_request_error400The body did not parse as JSON.
invalid_multipartinvalid_request_error400Transcriptions or voice create: the body is not multipart/form-data.
model_not_foundinvalid_request_error404No model by that id. param: "model".
model_not_supportedinvalid_request_error400The model exists but does not serve this endpoint. The message names where it lives.
method_not_allowedinvalid_request_error405Right path, wrong verb (GET on /chat/completions). The response carries an allow header naming what the route does accept.
request_too_largeinvalid_request_error413Over a size cap: 2,048 messages, 32,000 chars across messages, 8 MiB of audio, or 5,000 chars of input.
unknown_urlinvalid_request_error404A path under /api/v1 that is not one of the six routes. JSON here too, never an HTML 404. The message lists every route that does exist, straight from the route table the endpoints are registered in.
model_not_availablemodel_not_available503The model is in training, no gateway is connected, or the model is cold. A cold 27B load takes around 3 minutes, which is past the 120-second request timeout, so the gateway declines immediately rather than spending your request on a wait it cannot finish. Retry after a pause; this is the one 503 here that usually clears itself. See Models.
fixture_no_renderinvalid_request_error422Speech, on a deployment in demo mode only: there is no recording of that input, and it will not be answered with a recording of something else. Send one of the twelve sentences on Dido's page, or point at a live deployment. param: "input".
fixture_no_transcriptinvalid_request_error422Transcriptions, on a deployment in demo mode only: the uploaded audio is not one of the twelve clips this deployment shipped with, and it will not be answered with a guess. Send one of those clips, or point at a live deployment. param: "file".
fixture_no_cloneinvalid_request_error422Speech, on a deployment in demo mode only: the voice is a cloned voice and this deployment has no voice model, so it will not answer with one of Dido's built-in recordings dressed up as yours. Creating, listing and deleting voices all work here. Only speaking with one does not. param: "voice". See Voices.
consent_requiredinvalid_request_error400Creating a voice without consent=true. Enforced on the server, so a key does not skip what the dashboard asks for; nothing is forwarded. param: "consent".
sample_too_short · sample_too_noisy · multiple_speakers · unsupported_format · sample_too_largeinvalid_request_error400 / 413The sample sent to POST /api/v1/voices cannot build a voice. Each is explained in the rejection table, where sample_too_noisy turns out to be broader than its name and multiple_speakers is marked as specified but not yet enforced. param: "sample".
too_many_samplesinvalid_request_error400More than one sample file on a voice create. A voice is built from a single reference clip and the extras are not blended in, so they are refused rather than dropped without a word. param: "sample".
name_required · sample_requiredinvalid_request_error400Creating a voice with no name, or with no sample file attached.
name_too_longinvalid_request_error400A voice name over the documented limit. Refused rather than truncated: a voice created under a name you did not choose is worse than a 400. param: "name".
voice_limitinvalid_request_error409This account already holds as many cloned voices as it may. Delete one first. See Voices.
voice_not_foundinvalid_request_error400 / 404No such voice on this account. Another account's voice id gets exactly this, and is not distinguishable from a typo: voice ids are what protect voices. param: "voice" on speech, "voice_id" on the voices routes.
voice_not_readyinvalid_request_error409The cloned voice is still building. Nothing is wrong. Poll GET /api/v1/voices/{voice_id} until ready.
voice_failedinvalid_request_error400The cloned voice did not finish building and cannot be used. Delete it and create another from better samples.
builtin_not_deletableinvalid_request_error400DELETE on one of the built-in voices. They ship with the model and belong to no account.
delete_unconfirmedserver_error502A voice delete took effect here, so the voice is out of your list and refused by the speech endpoint, but the voice service did not confirm the reference audio was destroyed. Retried automatically; DELETE again to force another attempt.
upstream_errorserver_error502The gateway declined, returned nothing usable, or was unreachable.
nullinvalid_request_error400Simple field validation, where param already says everything: a missing model, malformed messages, a non-text content part, a non-boolean stream, a misplaced or malformed stream_options, a bad voice, a response_format that is not on the list, max_tokens not a positive integer, temperature outside 0–2.

#Headers and non-errors

  • Every response from an endpoint that read your key includes x-ratelimit-limit and x-ratelimit-remaining, on success and on error alike. A rejected request still spent a slot, so a client pacing on those headers is told the truth. See rate limits.
  • Four refusals carry no x-ratelimit-*, and for two different reasons. A 405 method_not_allowed and a 404 unknown_url for a path no endpoint claims are answered before the key is read at all: nothing was spent on either, which is the point: a mistyped path should not cost you a slot out of your minute, so there is no budget for them to report. The two 401s, missing_api_key and invalid_api_key, are refused at the door for the same reason from the other side: a key that did not authenticate has no account behind it, and an account is what the budget belongs to. A 429 rate_limit_exceeded does carry them, along with retry-after. Everything else a key that authenticated can provoke, every other 4xx and 5xx, reports one.
  • 405 responses add an allow header listing the methods the route serves.
  • OPTIONS on any real route answers 204 with an allow header and no body. It is a preflight answer rather than an error, and it needs no API key.
  • Errors are reported before a stream starts. Once SSE is flowing the status is already 200, so a failure mid-stream ends the stream without a [DONE] rather than changing the status code. Treat a missing terminator as an incomplete response.

#Rate limits

60 requests per account per minute, on a sliding window that cannot be gamed at the boundary.

Every account gets 60 requests per minute during the beta, across every key on it. The window slides over real timestamps rather than resetting on a fixed tick, so there is no boundary to burst across: at any instant, the last sixty seconds hold at most sixty requests.

HeaderOnMeaning
x-ratelimit-limitevery authenticated responseThe window size for the account this key belongs to.
x-ratelimit-remainingevery authenticated responseRequests left in the current window (0 on a 429). Two keys on one account watch the same number go down.
retry-after429 onlyWhole seconds until one slot opens. It is not a guess: it is when the oldest request in the window ages out, rounded up.

A refused request answers 429 with type rate_limit_error and code rate_limit_exceeded. The right response is to sleep retry-after seconds and retry the same request. No jitter is needed at one account's scale, and no exponential growth needed when the header already tells the truth:

Honouring retry-after
async function withRetry(request) {
  for (;;) {
    const res = await request();
    if (res.status !== 429) return res;
    const wait = Number(res.headers.get("retry-after") ?? 1);
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
}
  • Limits are per account, not per key. A second key is for keeping a laptop and a server apart, or for rotating one out without downtime; it is not more budget, and neither is revoking a key and minting another. An account holds at most 10 live keys, and creating them is itself capped at 10 an hour.
  • Watch x-ratelimit-remaining, which is on failed responses too, so a burst of 400s while you debug still shows you the budget it spent. Pace yourself before the 429 rather than treating the refusal as the pacing signal.
  • Streaming requests count once, at the request. A long reply does not eat the window.
  • Need more than 60/min for something real? Write in. The cap is a beta setting, not a business model.