Developer platform
API reference
Chat, coding, Punica Live voice conversations, transcription, speech, images and video for Tunisian Arabic, behind one HTTP API. Create a key in the dashboard, or try every request first in the playground.
Requests made with API keys use only your API credits at the published rates. Subscription allowances do not cover API calls. New accounts receive 10.000 TND in one-time platform starter credit. API credits are purchased separately, starting at 15 TND. Current API rates ↗
#Introduction
Language, speech and media models for Tunisian Arabic, behind one HTTP API.
Punica trains models for Tunisian Arabic (Derja, ISO 639-3 aeb). They read Arabic script, Tunizi (3aslema-style Latin letters) and French code-switching, and answer in Arabic-script Derja.
Language and speech models, Punica Live, plus image and video generation, with OpenAI-compatible shapes where they fit. The official openai packages work once you change base_url and the key.
https://punica.tn/api/v1| Endpoint | What it does |
|---|---|
GET /models | List the models, their capabilities and their status. |
POST /chat/completions | Chat with Hannibal in Derja, or code with Hannibal XL, streaming or not. |
POST /audio/transcriptions | Tunisian speech in, text out. |
POST /audio/speech | Text in, a Tunisian voice out: Dido, built-in voices or your own. |
GET POST /voices | The 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. |
POST /images/generations | A prompt or your own photo in, images out. See Images. |
POST /videos | Start a clip with sound, then poll GET /videos/{id} and download /videos/{id}/content. See Videos. |
Create a key in the dashboard; every request carries one. The playground runs the models in the browser and shows the request behind each run.
Models marked in_training are listed but decline requests with model_not_available until they ship.
#Live models and demo mode
A deployment runs in one of two modes. GET /api/health reports which, with one boolean per capability. In demo mode, every surface that shows model output carries a Demo badge.
{
"connected": true,
"chat": true,
"transcription": true,
"speech": true,
"mode": "live"
}connected is false when no models are attached; the model endpoints then answer 503 model_not_available. GET /api/v1/models and GET /api/v1/voices still answer 200. image and video appear as true when those are available.
- Live: requests reach the real models. A 503 means the gateway is down or a model is cold.
- Demo (fixtures): no GPU is attached, and the API answers from a small fixed corpus of real Hannibal replies and twelve real Dido clips. Chat answers anything.
- Speech returns audio only for one of the twelve rendered sentences, listed in the text-to-speech playground. Spacing, harakat, quotes and final punctuation are ignored when matching. Anything else gets
422 fixture_no_render. - Transcription recognises the same twelve clips by byte length and returns their true transcripts. Any other audio gets
422 fixture_no_transcript. - Cloned voices can be created, listed and deleted, but not spoken: speech with a cloned
voicegets422 fixture_no_clone. - Chat parameters:
systempicks a recorded register (French or Arabic script), andtemperaturecontrols how far the pick wanders among recorded replies. Neither is real instruction-following or sampling.
#Authentication
One bearer key per request. Keys are shown once and stored hashed.
Create keys in the dashboard. A key looks like pk_live_ followed by 24 characters. It is shown once, at creation; after that only its hash is stored, so a lost key is revoked and replaced.
Send the key on every request as a bearer token:
Authorization: Bearer pk_live_…
# most tools read it from the environment:
export BAARCHA_API_KEY="pk_live_…"A missing key answers 401 missing_api_key; a wrong or revoked key answers 401 invalid_api_key.
#Keeping it safe
- Call the API from a server, never from a browser or a mobile app.
- Keep the key in an environment variable or a secret store, out of source control.
- Use one key per app or environment, up to 10 live keys. Rate limits are counted per account, not per key.
- If a key leaks, revoke it and create a new one. Revocation is instant.
#Quickstart
A key, one request, and a streamed reply in Derja.
Grab a key from the dashboard, top up API credits (15 TND minimum), export the key, and ask Hannibal something. The openai client libraries work as-is: point base_url at https://punica.tn/api/v1.
# curl is all you need. No SDK, no install.
export BAARCHA_API_KEY="pk_live_…"curl https://punica.tn/api/v1/chat/completions \
-H "Authorization: Bearer $BAARCHA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "hannibal",
"messages": [
{"role": "user", "content": "عسلامة، شنية أحوالك اليوم؟"}
]
}'{
"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.
curl -N https://punica.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
Language and speech models, Punica Live, plus image and video generation.
| Model | Serves | Status | Notes |
|---|---|---|---|
hannibal | POST /chat/completions | available | Hannibal, a 27B model for chat, translation and writing in Derja, with image and video understanding. |
hannibal-xl | POST /chat/completions | available | Hannibal XL, Punica's largest model, for coding: writing and reviewing code, debugging, and long technical context. |
fennec | POST /audio/transcriptions | available | Fennec: 581M CTC speech encoder with 5-gram language model decoding for Tunisian speech and French code-switching. |
dido | POST /audio/speech | available | Dido, a 613M model for Derja speech and zero-shot voice cloning. |
baarcha-live | POST /live/sessions | available | Streaming voice conversations in Tunisian Derja and French, with microphone input, selectable voices and client function tools. |
- baarcha-live: streaming voice conversations, microphone captions and client function tools. See Punica Live.
- hannibal: 27B parameters, 32,768-token context. Chat, translation and writing in Derja, with image and video understanding.
- hannibal-xl: our largest model, for coding. Same request as
hannibal. - fennec: 581M speech encoder for Tunisian speech and French code-switching. See Transcriptions.
- dido: 613M model for Derja speech and voice cloning. See Speech and Voices.
Image and video generation have no model id; call them by endpoint. See Images and Videos.
GET /api/v1/models returns the same catalogue. Models still in training are listed with "status": "in_training" and decline requests with 503 `model_not_available` until they ship.
curl https://punica.tn/api/v1/models \
-H "Authorization: Bearer $BAARCHA_API_KEY"{
"object": "list",
"data": [
{
"id": "hannibal",
"object": "model",
"created": 1751328000,
"owned_by": "baarcha",
"capabilities": [
"chat"
],
"status": "available",
"description": "Hannibal, a 27B model for chat, translation and writing in Derja, with image and video understanding.",
"parameters": "27B"
},
{
"id": "dido",
"object": "model",
"created": 1754870400,
"owned_by": "baarcha",
"capabilities": [
"speech"
],
"status": "available",
"description": "Dido, a 613M model for Derja speech and zero-shot voice cloning.",
"parameters": "613M"
}
]
}#Chat completions
Chat in Derja with Hannibal, or code with Hannibal XL.
POST/api/v1/chat/completions
Creates a model response for a conversation. The shapes are OpenAI's, so the openai client libraries work unchanged.
Request body
modelstringrequiredhannibalfor conversation, translation and writing in Derja, orhannibal-xlfor coding.messagesarrayrequired- Objects with
role(system,userorassistant) andcontent, a string or an array of{type: "text", text}parts. At least oneusermessage is required. The latest user message may add up to four{type: "image_url", image_url: {url: "data:image/jpeg;base64,..."}}parts (PNG, JPEG, WebP or GIF data URLs, 6 MiB each). Limits: 2,048 messages and 32,000 characters (413request_too_large). The last 24 turns are kept as context. streambooleanoptional- Default
false. Whentrue, the response is server-sent events. Must be a boolean. stream_optionsobjectoptional- Only with
stream: true.include_usage: trueadds a usage chunk before[DONE]. max_tokensintegeroptional- Cap on the reply length, counted in streamed deltas (roughly one word each). A cut reply ends with
finish_reason: "length". temperaturenumberoptional- Sampling temperature from 0 to 2. Defaults to the model's own.
#Coding with Hannibal XL
Set model to hannibal-xl. Everything else stays the same; raise max_tokens for long answers such as a whole file.
{
"model": "hannibal-xl",
"messages": [
{ "role": "system", "content": "You are reviewing a Next.js codebase. Answer with code." },
{ "role": "user", "content": "Write a debounce hook in TypeScript with a cancel function." }
],
"stream": true
}#Response
Without stream, one chat.completion object. choices[0].message holds the reply, finish_reason is "stop" or "length", and usage carries the model's own prompt_tokens and completion_tokens.
#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 ends with the line data: [DONE].
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]Split events on the blank line between them, not on network reads, and skip [DONE] before JSON.parse. The quickstart 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]:
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]#Code-switching and script
- Write in Arabic script (عسلامة), Tunizi (
chnwa a7welek?) or French, mixed freely. - Replies come back in Arabic script, with French where a speaker would switch. Keep the mix; it is the register.
- Tunizi is understood but not written: replies are Arabic-script Derja, and there is no
languageparameter.
#Rendering Derja (RTL) correctly
- Set direction per message with
dir="auto"orunicode-bidi: plaintext, neverdirection: rtlon the whole surface. - In code or logs, keep the container LTR and wrap Arabic values in
<bdi>or Unicode isolates (U+2066 to U+2069). - For Arabic body text, use a Naskh face with a generous line-height (about 1.9).
#Punica Live
Spoken conversations in Tunisian Derja, on one persistent connection.
Punica Live uses Hannibal Bolt E4B for conversation, Fennec for speech recognition and Dido for your chosen voice. Try it in the playground, or connect your application with model baarcha-live.
POST/api/v1/live/sessions
Create a session on your server using your Punica API key. Send JSON with model: baarcha-live and, for browser clients, origin set to your exact application origin. The response contains a WebSocket url and a single-use client_secret.value, valid for 60 seconds. Give the browser this credential; keep your API key on your server.
# Create a single-use connection credential on your server.
curl https://punica.tn/api/v1/live/sessions \
-H "Authorization: Bearer $BAARCHA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"baarcha-live"}'
# Connect to the returned WebSocket URL within 60 seconds.
# See the JavaScript or Python tab for a complete spoken turn.#Stream a microphone
Connect to wss://punica.tn/api/v1/live/stream with subprotocols baarcha-live and ticket.<client_secret.value>. A session.ready event confirms the call. Send type: start, wait for ready, then send binary PCM16 little-endian frames at 16 kHz, mono. Frames must be even-sized and at most 64 KiB; 20–32 ms frames are recommended. Send type: finalize at the end of speech.
// Your server creates a session with its API key:
// POST /api/v1/live/sessions
// { "model": "baarcha-live", "origin": "https://your-app.example" }
// Return the response to your signed-in browser. Never expose the API key.
const ws = new WebSocket(connection.url, ["baarcha-live", "ticket." + connection.client_secret.value]);
ws.onopen = () => ws.send(JSON.stringify({
type: "start", model: "baarcha-live", voice: "nour_tn", // Any ready voice ID from GET /api/v1/voices.
sample_rate: 16000, channels: 1, format: "pcm_s16le",
audio_format: "opus", history: [],
}));
ws.binaryType = "arraybuffer";
ws.onmessage = ({ data }) => {
if (data instanceof ArrayBuffer) { /* decode this Ogg Opus segment and queue playback */ return; }
const event = JSON.parse(data);
if (event.type === "ready") { /* send microphone PCM frames now */ }
if (event.type === "partial" || event.type === "final") console.log(event.text);
if (event.audio?.wav_b64) { /* WAV fallback: decode and queue playback */ }
if (event.done) { /* keep history; start the next turn when ready */ }
if (event.type === "session.billing_started") { /* start the call timer */ }
if (event.type === "session.ended") console.log(event.usage.billable_seconds);
};
// After ready: ws.send(pcm16LittleEndianArrayBuffer); repeat while speaking.
// Detect speech locally (for example, Silero v5): 300 ms of quiet ends a turn.
// Keep 320 ms pre-roll; forward only 96 ms of trailing quiet. Restore held
// frames if speech resumes. Measure response time from the last speech frame.
// End of speech: ws.send(JSON.stringify({ type: "finalize" }));
// Interrupt a turn: stop local playback; ws.send(JSON.stringify({ type: "cancel" }));
// After turn.cancelled, send the next start/text turn with updated history.
// End the call and stop billing: ws.send(JSON.stringify({ type: "end" }));
// Closing the socket also ends the call. Do not leave unused calls connected.Fields on each start or text turn
modelstringrequiredbaarcha-live.typestringrequiredstartfor microphone input ortextfor a typed message.messagestringoptional- Required on a text turn. May be empty when continuing after tool results.
systemstringoptional- Application instructions. Omit for conversational Derja.
historyarrayoptional- Prior user, assistant and tool messages. Include the conversation on every turn; the connection does not store model history for you. Up to 100 messages, with 32,000 characters total across instructions, message and history.
voicestringoptional- A ready voice ID from GET /api/v1/voices: any built-in voice or a clone owned by your account. Defaults to
nour_tn(Nour);nourremains an alias. Send it on each start or text turn, including tool continuations. Unknown, deleted and unready voices are rejected. audio_formatstringoptionalopusis recommended for low latency on both text and microphone turns. Eachaudiometadata event is followed by a binary Ogg Opus segment.wavremains the compatibility default; WAV audio arrives asaudio.wav_b64. Handle WAV fallback even when requesting Opus, and queue decoded segments inaudio.seqorder.playback_readybooleanoptional- Set to
trueafter activating your audio output (for example, resuming an AudioContext). This reduces the first segment’s speaker wake-up silence from 200 ms to 20 ms. Defaults tofalsefor clients with cold audio devices. The playground enables it automatically. toolsarrayoptional- Up to 32 function definitions, using the chat tool schema.
tool_choicestring or objectoptionalauto,none,required, or a named function choice.
| Event | Meaning |
|---|---|
partial / final | Recognition text in text. |
delta | A fragment of the assistant's text. |
audio | A spoken segment; WAV base64 by default. |
done: true | Turn completed. Check success and finish_reason. Keep the connection open for the next turn. |
session.billing_started | First AI audio is being sent; call time starts here. |
turn.cancelled | The active turn stopped. Send the next start or text turn. |
session.ended | Final receipt in usage.billable_seconds, with an end reason. The socket closes. |
error | Call failed. Read code and message; do not treat partial output as a complete reply. |
To interrupt, stop your local audio playback and send type: cancel. Wait for turn.cancelled before starting another turn. Cancellation keeps the call connected and the billing clock running. Send type: end or close the socket to end the call. Abrupt network loss ends when the server detects the disconnect.
#Low-latency conversations
The playground uses local Silero v5 speech detection, a 300 ms end-of-speech wait, 320 ms pre-roll and up to 96 ms of trailing silence. It holds additional silence and restores it if speech resumes; this lets Fennec reuse its latest partial transcript. API clients control when to send finalize and should implement the same timing for comparable latency. A longer silence window can suit callers who pause frequently.
The gateway prepares the next authenticated backend connection during the reply, without starting inference or billing for warmup. Keep the session connected, prepare capture before the caller speaks, and request audio_format: opus. Decode each complete segment immediately and schedule playback without waiting for done. Keep capturing with echo cancellation; on sustained speech during a reply, stop playback, send cancel, and retain microphone pre-roll while waiting for turn.cancelled and the next ready. Measure response time from the last detected speech frame to audible playback, excluding the caller's speaking time.
For timing diagnostics, each audio segment includes tts_seconds, t_ready and qc.retried; the final event includes timing.first_delta and timing.first_audio_ready. These are worker timings, excluding client speech detection and network travel. A quality retry can make some voices or utterances slower. Measure end-to-end latency at playback rather than treating a worker timing as the caller’s response time.
#Client tools
Function calls are returned in tool_calls with finish_reason: tool_calls. Run only tools your application authorizes. Add the assistant tool-call message and each tool result (with its matching tool_call_id) to history, then send another text turn with an empty message. Punica does not execute your tools. Tool-only turns before the first spoken audio do not start billing.
history.push({ role: "assistant", tool_calls: event.tool_calls });
// Execute the requested tool in your application, after validation.
history.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
ws.send(JSON.stringify({ type: "text", model: "baarcha-live", message: "", voice: "nour_tn", history, tools }));#Limits and credit
One concurrent call per account; up to 60 seconds of microphone audio per turn, 100 turns per call and 10 billed minutes per call. A call also ends after 60 seconds without a new turn, on stalled input, or when credit runs out. API integrations use API credit; the playground uses platform credit. An expired, reused or revoked credential cannot open another connection: request a new session.
#Transcriptions
Tunisian speech in, Tunisian text out.
POST/api/v1/audio/transcriptions
Transcribes spoken Derja with Fennec (581M). Send multipart/form-data.
Form fields
filefilerequired- The audio, up to 8 MiB, any container and sample rate. The filename extension (
.mp3,.wav,.webm,.m4a,.ogg) is the container hint; without one it is treated as.webm. modelstringrequiredfennec.response_formatstringoptionaljson(default) orverbose_json. Anything else is a 400.
curl https://punica.tn/api/v1/audio/transcriptions \
-H "Authorization: Bearer $BAARCHA_API_KEY" \
-F file=@recording.mp3 \
-F model=fennec{ "text": "شنوا رايك نعملو طلة على السوق الجديد؟" }With response_format=verbose_json, two fields join the text:
{
"task": "transcribe",
"language": "aeb",
"text": "الشارع مسكر اليوم على خاطر الأشغال، لازم نعملو دورة طويلة باش نوصلو للخدمة في الوقت."
}language is always aeb (Tunisian Arabic). There are no word timestamps yet.
Transcripts are Arabic script, with code-switched French kept in Latin script. See the rendering notes.
#Reading a transcript
3is the letter ع7is the letter ح9is the letter ق
#Usage: duration, not tokens
Transcription bills by seconds of audio, and usage says so:
{ "usage": { "type": "duration", "seconds": 5 } }Branch on type when you handle several modalities. Speech reports usage in headers.
#Speech
Text in, a Maghreb voice out. Six dialects, built-in voices at 24 kHz, or one of your own.
POST/api/v1/audio/speech
Renders Arabic dialect text as audio with Dido, our 613M speech model. One model speaks Tunisian, Moroccan, Algerian, Libyan, Egyptian and Saudi; language picks which. The response body is the audio bytes.
Request body
modelstringrequired- Always
dido. inputstringrequired- The text to speak, at most 5,000 characters. Arabic script, Tunizi and French are accepted.
voicestringoptional- A built-in voice name, or the id of one of your cloned voices (
vc_…). The built-in set changes, so read it from GET /api/v1/voices. See Voices to clone one. languagestringoptional- The dialect to speak, as an ISO 639-3 id:
aebTunisian,aryMoroccan,arqAlgerian,aylLibyan,arzEgyptian,arsSaudi. Omitted, Arabic text is read as Tunisian. Write the text in the dialect you name; the id conditions pronunciation and rhythm, it does not translate. A built-in voice that carries its own dialect model ignores it. stylestringoptional- A delivery style the engine understands:
natural(no instruction at all),whisper(soft, breathy),low(deeper),high(brighter),elderly(an older voice). Omitted, each voice keeps its tuned delivery. speednumberoptional- Speaking rate as a multiplier of the voice's own pace, 0.5 to 2. Omitted, each voice keeps its tuned pace.
response_formatstringoptionalwav(24 kHz, mono, 16-bit PCM),mp3oropus. Check the responseContent-Type.
curl https://punica.tn/api/v1/audio/speech \
-H "Authorization: Bearer $BAARCHA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "dido",
"input": "أمي ديما تقعد تحكي على طفولتها و تحسها ما زالت صغيرة عندي.",
"voice": "dido"
}' \
--output speech.mp3#Expression tags
Put a tag in square brackets where the sound belongs and Dido performs it instead of reading it: [laughter] a short laugh, [sigh] an audible sigh, [surprise-ah] a surprised “ah”, [surprise-oh] a surprised “oh”, [question-en] a questioning hum, [confirmation-en] an agreeing hum, [dissatisfaction-hnn] a displeased hum. Tags count toward the character limit. Anything else in brackets is read as text.
{"model": "dido", "input": "عسلامة، [laughter] والله ضحكتني برشا، شنوة الحكاية هذي؟", "voice": "dido"}#Audio length and wait
The whole utterance is synthesized before any audio returns, so wait time grows with the text. For earlier playback, split long text into sentences and render them in sequence.
#What a render costs
Usage arrives in headers: X-Usage-Characters is what you sent, and X-Usage-Audio-Seconds, the billing unit, is what came back.
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 a built-in name or a cloned voice id. Both come from one list.
#List voices
GET/api/v1/voices
The built-in voices and this account's cloned voices. The built-in set changes, so read it rather than hard-coding names.
{
"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
Send multipart/form-data. The build takes a second or two and the response is the finished voice, "status": "ready".
Request body (multipart)
namestringrequired- Up to 60 characters, Arabic script included.
samplefilerequired- One recording, under 10 MB:
wav,mp3,m4a,webm,ogg,flac. consentstringrequiredtrue, affirming you have the right to clone this voice.
- Between 3 and 30 seconds; longer clips are trimmed. 10 to 15 seconds of continuous speech works well.
- One speaker, somewhere quiet. Background noise is learned along with the voice.
- Up to 5 cloned voices per account (admins have no limit); a create beyond that is a
409 voice_limit. - The container comes from the part's content type or the file extension, so
-F "sample=@take-one.wav"is enough.
curl https://punica.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, processing or failed; a failed voice carries an error. A voice not on this account answers 404 voice_not_found.
curl https://punica.tn/api/v1/voices/vc_97e9a9dc0da8bd91 \
-H "Authorization: Bearer $BAARCHA_API_KEY"#Delete a voice
DELETE/api/v1/voices/{voice_id}
Deletes the voice and its reference audio permanently before responding.
If the audio's removal cannot be confirmed, the answer is 502 delete_unconfirmed: the voice is already unusable, the delete is retried automatically, and calling DELETE again forces another attempt.
Built-in voices cannot be deleted (400 builtin_not_deletable).
#Speak with a cloned voice
Pass the id as voice on POST /api/v1/audio/speech. Everything else about that endpoint is unchanged.
curl https://punica.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 building | 409 voice_not_ready. Re-read the voice and retry once it is ready. |
failed | 400 voice_failed. It cannot be used. Delete it and create another from better samples. |
| not on this account | 400 voice_not_found. |
#Why a create is refused
All refusals share the error envelope.
| `code` | Status | What to do |
|---|---|---|
sample_too_short | 400 | Record at least 3 seconds of continuous speech. |
sample_too_noisy | 400 | No clear speech was found: too noisy, near-silent or music. Record somewhere quieter, closer to the microphone, and keep talking. |
multiple_speakers | 400 | More than one person is audible. Reserved; not enforced yet. |
unsupported_format | 400 | Send wav, mp3, m4a, webm, ogg, flac with a matching file extension. |
sample_too_large | 400 / 413 | The sample is over 10 MB. |
too_many_samples | 400 | Attach 1 sample file only. |
voice_limit | 409 | This account already holds 5 voices. Delete one first. |
consent_required | 400 | consent=true was missing. Nothing was forwarded to the voice service. |
name_required | 400 | name was empty. |
name_too_long | 400 | name is over 60 characters. |
sample_required | 400 | No sample file was attached. |
#Voices in demo mode
In demo mode voices are created, listed and deleted for real, but speech with a cloned voice gets 422 fixture_no_clone. Put noisy or two-speakers in a sample's file name to rehearse those rejections; the response carries "simulated": true.
#Images
Generate images from a prompt, or edit your own photos.
POST/api/v1/images/generations
Images come back as base64. There is no model field.
Request body
promptstringrequired- At most 1000 characters. For a fresh image, a plain description; it is expanded server-side into the detailed caption the model works from. With
images, a short instruction naming only the change:Show only the teapot on a plain white background. aspect_ratiostringoptional1:1(default),16:9,9:16,4:3,3:4,3:2,2:3or21:9.nintegeroptional- Variants of the same request,
1to4. Default1. imagesarrayoptional- Up to 4 base64 data URLs (
data:image/png;base64,…, PNG, JPEG or WebP, 6 MiB each) to edit or take a subject from. Remote URLs are not fetched. requestsarrayoptional- A batch: 1 to 8 objects, each with its own
prompt,aspect_ratioandimages. Send eitherrequestsor a singleprompt(withn), not both. A batch is rendered together, so eight product shots cost one wait rather than eight.
curl https://punica.tn/api/v1/images/generations \
-H "Authorization: Bearer $BAARCHA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Product photo of a ceramic Nabeul teapot on a plain white background, soft daylight",
"aspect_ratio": "1:1"
}' | jq -r '.data[0].b64_json' | base64 --decode > teapot.png#Response
data keeps the order of your request, one entry per image. An image that could not be made is an error entry in its place, so one failure never costs you the rest of a batch. If none came back at all the request is a 502 upstream_error.
{
"created": 1792627200,
"data": [
{ "b64_json": "iVBORw0KGgoAAAANSUhEUgAA…", "mime_type": "image/png" },
{ "error": { "message": "The image model could not produce this image. Retry shortly.", "code": "upstream_error" } }
]
}#Editing your own photo
Send the photo in images and say only what should change. The subject is kept; the instruction decides the rest. Several references work too, for example a product and a setting to put it in.
{
"prompt": "Put the teapot on a carved wooden tray, keep the lighting soft",
"images": ["data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ…"],
"aspect_ratio": "4:3"
}#Videos
Generate short clips with sound from a prompt or a starting image.
POST/api/v1/videos
Answers 202 with a video object. Poll GET /api/v1/videos/{id} until status is completed, then download the mp4 from /content. There is no model field.
Request body
promptstringrequired- The scene, at most 2000 characters. Describe motion and camera as well as subject.
aspect_ratiostringoptional16:9(default),9:16or1:1.duration_secondsnumberoptional- Length of the clip. The allowed range comes from the video model and is checked on every request; today it is 5 to 14 seconds. A value outside it is a 400 whose message gives the current range. Omit it for the model's default.
seedintegeroptional0to2147483647. The same seed and inputs give a similar clip. Random when omitted, and returned on the video object either way.enhance_promptbooleanoptional- Default
true: the prompt is rewritten into the detailed shot description the model works best from.falsesends your words as they are. imagestringoptional- A base64 data URL (PNG, JPEG or WebP, 6 MiB) to use as the first frame. Turns a photo into a moving shot.
end_imagestringoptional- A data URL for the last frame. Needs
image.
# 1. Start the clip
curl https://punica.tn/api/v1/videos \
-H "Authorization: Bearer $BAARCHA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "A fisherman mending nets at dawn in the old port of Bizerte, slow camera push-in",
"aspect_ratio": "16:9",
"duration_seconds": 8
}'
# 2. Poll with the id it returned until "status" is "completed"
curl https://punica.tn/api/v1/videos/$VIDEO_ID \
-H "Authorization: Bearer $BAARCHA_API_KEY"
# 3. Download the mp4
curl https://punica.tn/api/v1/videos/$VIDEO_ID/content \
-H "Authorization: Bearer $BAARCHA_API_KEY" \
--output clip.mp4#The video object
status moves from queued to in_progress to completed, or ends at failed with an error. duration_seconds appears once the clip is done and is its measured length.
{
"id": "6f1c2b9e4d7a4c0e9b8f3a215d6e7c40",
"object": "video",
"status": "completed",
"created_at": 1792627200,
"seed": 184467021,
"duration_seconds": 8.04
}#Polling a clip
GET/api/v1/videos/{id}
Returns the video object. Every ten seconds is plenty; a clip usually takes a few minutes. A 404 not_found means no clip with that id belongs to this key's account. Each poll counts toward your rate limit.
#Downloading the mp4
GET/api/v1/videos/{id}/content
The finished clip as video/mp4. A completed clip is also saved to your account's media library, so it stays downloadable here after the job itself is gone. Before it completes this answers 404 not_found.
#Errors
Every error uses one envelope, with a type to branch on and a code for detail.
Errors are JSON in OpenAI's envelope: message is readable, type is the family, code is the precise reason (or null), and param names the field.
{
"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-authenticate, fix the request) and on code for detail. Show users message, not code.
#Types
| Type | Status | Meaning |
|---|---|---|
invalid_request_error | 400 / 404 / 405 / 409 / 413 / 422 | The request is wrong. Fix it before retrying. |
authentication_error | 401 | Missing or unknown key. Check the dashboard. |
rate_limit_error | 402 / 429 | 402: not enough credit. 429: too many requests this minute; wait retry-after seconds. See rate limits. |
model_not_available | 503 | The model is in training, loading or unreachable. |
server_error | 502 | The upstream failed. Retry with backoff. |
#Every code
| Code | Type | Status | When |
|---|---|---|---|
missing_api_key | authentication_error | 401 | No Authorization: Bearer … header. |
invalid_api_key | authentication_error | 401 | Unknown or revoked key. |
rate_limit_exceeded | rate_limit_error | 429 | The account's window is full. Carries retry-after. |
invalid_json | invalid_request_error | 400 | The body did not parse as JSON. |
invalid_multipart | invalid_request_error | 400 | Transcriptions or voice create: the body is not multipart/form-data. |
model_not_found | invalid_request_error | 404 | No model by that id. param: "model". |
model_not_supported | invalid_request_error | 400 | The model exists but does not serve this endpoint. The message names where it lives. |
method_not_allowed | invalid_request_error | 405 | Wrong method for the path. The allow header lists the right ones. |
request_too_large | invalid_request_error | 413 | Over a size cap: 2,048 messages, 32,000 chars across messages, 8 MiB of audio, or 5,000 chars of input. |
unknown_url | invalid_request_error | 404 | No such route under /api/v1. The message lists the routes that exist. |
model_not_available | model_not_available | 503 | In training, unavailable or loading. Retry after Retry-After. |
fixture_no_render | invalid_request_error | 422 | Demo mode speech: no recording of that input. |
fixture_no_transcript | invalid_request_error | 422 | Demo mode transcription: not one of the shipped clips. |
fixture_no_clone | invalid_request_error | 422 | Demo mode speech with a cloned voice. See Voices. |
consent_required | invalid_request_error | 400 | Creating a voice without consent=true. |
sample_too_short · sample_too_noisy · multiple_speakers · unsupported_format · sample_too_large | invalid_request_error | 400 / 413 | The sample cannot build a voice. See why a create is refused. |
too_many_samples | invalid_request_error | 400 | More than one sample on a voice create. |
name_required · sample_required | invalid_request_error | 400 | Creating a voice with no name, or with no sample file attached. |
name_too_long | invalid_request_error | 400 | A voice name over the limit. |
voice_limit | invalid_request_error | 409 | Cloned voice limit reached. Delete one first. |
voice_not_found | invalid_request_error | 400 / 404 | No such voice on this account. |
voice_not_ready | invalid_request_error | 409 | The cloned voice is still building. Poll the voice until ready. |
voice_failed | invalid_request_error | 400 | The cloned voice failed to build. Create another. |
builtin_not_deletable | invalid_request_error | 400 | Built-in voices cannot be deleted. |
delete_unconfirmed | server_error | 502 | The voice is gone but audio removal is unconfirmed. Retried automatically; DELETE again to force it. |
upstream_error | server_error | 502 | The upstream failed or returned nothing usable. |
insufficient_credit | rate_limit_error | 402 | Not enough credit for images or videos. Nothing was charged. Add credit. |
library_full | invalid_request_error | 409 | Videos: your media library is full. |
not_found | invalid_request_error | 404 | Videos: no such clip, or /content before it completed. |
null | invalid_request_error | 400 | Field validation; param names the field. |
#Headers and non-errors
- Authenticated responses carry
x-ratelimit-limitandx-ratelimit-remaining, errors included.401,405and unknown-route404s do not. 405responses add anallowheader.OPTIONSon a route answers204withallow, no key needed.- Errors arrive before a stream starts. A stream that ends without
[DONE]is incomplete.
#Rate limits
60 requests per account per minute, on a sliding window.
Every account gets 60 requests per minute during the beta, shared by all its keys. At any moment, the last sixty seconds hold at most sixty requests.
| Header | On | Meaning |
|---|---|---|
x-ratelimit-limit | every authenticated response | The window size for the account this key belongs to. |
x-ratelimit-remaining | every authenticated response | Requests left in the current window (0 on a 429). |
retry-after | 429 only | Whole seconds until a slot opens. |
A refused request answers 429 rate_limit_exceeded. Sleep retry-after seconds and retry:
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. Extra keys separate apps; they do not add budget. Up to 10 live keys, created at most 10 an hour.
- Pace on
x-ratelimit-remaining, which errors carry too. - A streamed request counts once.
- Need more than 60 a minute? Write to us.