punica

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.

Base URL
https://punica.tn/api/v1
EndpointWhat it does
GET /modelsList the models, their capabilities and their status.
POST /chat/completionsChat with Hannibal in Derja, or code with Hannibal XL, 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.
POST /images/generationsA prompt or your own photo in, images out. See Images.
POST /videosStart 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.

GET /api/health
{
  "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 voice gets 422 fixture_no_clone.
  • Chat parameters: system picks a recorded register (French or Arabic script), and temperature controls 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:

The header
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.

Install
# curl is all you need. No SDK, no install.
export BAARCHA_API_KEY="pk_live_…"
Your first chat completion
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": "عسلامة، شنية أحوالك اليوم؟"}
    ]
  }'
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://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.

ModelServesStatusNotes
hannibalPOST /chat/completionsavailableHannibal, a 27B model for chat, translation and writing in Derja, with image and video understanding.
hannibal-xlPOST /chat/completionsavailableHannibal XL, Punica's largest model, for coding: writing and reviewing code, debugging, and long technical context.
fennecPOST /audio/transcriptionsavailableFennec: 581M CTC speech encoder with 5-gram language model decoding for Tunisian speech and French code-switching.
didoPOST /audio/speechavailableDido, a 613M model for Derja speech and zero-shot voice cloning.
baarcha-livePOST /live/sessionsavailableStreaming 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.

List models
curl https://punica.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",
      "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

modelstringrequired
hannibal for conversation, translation and writing in Derja, or hannibal-xl for coding.
messagesarrayrequired
Objects with role (system, user or assistant) and content, a string or an array of {type: "text", text} parts. At least one user message 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 (413 request_too_large). The last 24 turns are kept as context.
streambooleanoptional
Default false. When true, the response is server-sent events. Must be a boolean.
stream_optionsobjectoptional
Only with stream: true. include_usage: true adds 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.

Request body
{
  "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].

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]

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]:

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]

#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 language parameter.

#Rendering Derja (RTL) correctly

  • Set direction per message with dir="auto" or unicode-bidi: plaintext, never direction: rtl on 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.

Start a call and receive a spoken reply
# 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.

Code
// 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

modelstringrequired
baarcha-live.
typestringrequired
start for microphone input or text for 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); nour remains an alias. Send it on each start or text turn, including tool continuations. Unknown, deleted and unready voices are rejected.
audio_formatstringoptional
opus is recommended for low latency on both text and microphone turns. Each audio metadata event is followed by a binary Ogg Opus segment. wav remains the compatibility default; WAV audio arrives as audio.wav_b64. Handle WAV fallback even when requesting Opus, and queue decoded segments in audio.seq order.
playback_readybooleanoptional
Set to true after 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 to false for clients with cold audio devices. The playground enables it automatically.
toolsarrayoptional
Up to 32 function definitions, using the chat tool schema.
tool_choicestring or objectoptional
auto, none, required, or a named function choice.
EventMeaning
partial / finalRecognition text in text.
deltaA fragment of the assistant's text.
audioA spoken segment; WAV base64 by default.
done: trueTurn completed. Check success and finish_reason. Keep the connection open for the next turn.
session.billing_startedFirst AI audio is being sent; call time starts here.
turn.cancelledThe active turn stopped. Send the next start or text turn.
session.endedFinal receipt in usage.billable_seconds, with an end reason. The socket closes.
errorCall 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.

Code
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.
modelstringrequired
fennec.
response_formatstringoptional
json (default) or verbose_json. Anything else is a 400.
Transcribe a clip
curl https://punica.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 (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

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

#Usage: duration, not tokens

Transcription bills by seconds of audio, and usage says so:

usage on a transcription response
{ "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: aeb Tunisian, ary Moroccan, arq Algerian, ayl Libyan, arz Egyptian, ars Saudi. 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_formatstringoptional
wav (24 kHz, mono, 16-bit PCM), mp3 or opus. Check the response Content-Type.
Render a sentence
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
0:00 / 0:00
The sentence above in the female voice, from an earlier Dido deployment: "My mother always talks about her childhood, and she still feels like a little girl to me."

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

A laugh mid-sentence
{"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.

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

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

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.
consentstringrequired
true, 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.
Create a voice
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.

Read one voice
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.

Render with a clone
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 building409 voice_not_ready. Re-read the voice and retry once it is ready.
failed400 voice_failed. It cannot be used. Delete it and create another from better samples.
not on this account400 voice_not_found.

#Why a create is refused

All refusals share the error envelope.

`code`StatusWhat to do
sample_too_short400Record at least 3 seconds of continuous speech.
sample_too_noisy400No clear speech was found: too noisy, near-silent or music. Record somewhere quieter, closer to the microphone, and keep talking.
multiple_speakers400More than one person is audible. Reserved; not enforced yet.
unsupported_format400Send wav, mp3, m4a, webm, ogg, flac with a matching file extension.
sample_too_large400 / 413The sample is over 10 MB.
too_many_samples400Attach 1 sample file only.
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.
sample_required400No 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_ratiostringoptional
1:1 (default), 16:9, 9:16, 4:3, 3:4, 3:2, 2:3 or 21:9.
nintegeroptional
Variants of the same request, 1 to 4. Default 1.
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_ratio and images. Send either requests or a single prompt (with n), not both. A batch is rendered together, so eight product shots cost one wait rather than eight.
Generate an image
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.

Response
{
  "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.

Edit request body
{
  "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_ratiostringoptional
16:9 (default), 9:16 or 1: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.
seedintegeroptional
0 to 2147483647. 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. false sends 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.
Generate a clip and download it
# 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.

Response
{
  "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.

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-authenticate, fix the request) and on code for detail. Show users message, not code.

#Types

TypeStatusMeaning
invalid_request_error400 / 404 / 405 / 409 / 413 / 422The request is wrong. Fix it before retrying.
authentication_error401Missing or unknown key. Check the dashboard.
rate_limit_error402 / 429402: not enough credit. 429: too many requests this minute; wait retry-after seconds. See rate limits.
model_not_available503The model is in training, loading or unreachable.
server_error502The upstream failed. Retry with backoff.

#Every code

CodeTypeStatusWhen
missing_api_keyauthentication_error401No Authorization: Bearer … header.
invalid_api_keyauthentication_error401Unknown or revoked key.
rate_limit_exceededrate_limit_error429The account's 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_error405Wrong method for the path. The allow header lists the right ones.
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_error404No such route under /api/v1. The message lists the routes that exist.
model_not_availablemodel_not_available503In training, unavailable or loading. Retry after Retry-After.
fixture_no_renderinvalid_request_error422Demo mode speech: no recording of that input.
fixture_no_transcriptinvalid_request_error422Demo mode transcription: not one of the shipped clips.
fixture_no_cloneinvalid_request_error422Demo mode speech with a cloned voice. See Voices.
consent_requiredinvalid_request_error400Creating a voice without consent=true.
sample_too_short · sample_too_noisy · multiple_speakers · unsupported_format · sample_too_largeinvalid_request_error400 / 413The sample cannot build a voice. See why a create is refused.
too_many_samplesinvalid_request_error400More than one sample on a voice create.
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 limit.
voice_limitinvalid_request_error409Cloned voice limit reached. Delete one first.
voice_not_foundinvalid_request_error400 / 404No such voice on this account.
voice_not_readyinvalid_request_error409The cloned voice is still building. Poll the voice until ready.
voice_failedinvalid_request_error400The cloned voice failed to build. Create another.
builtin_not_deletableinvalid_request_error400Built-in voices cannot be deleted.
delete_unconfirmedserver_error502The voice is gone but audio removal is unconfirmed. Retried automatically; DELETE again to force it.
upstream_errorserver_error502The upstream failed or returned nothing usable.
insufficient_creditrate_limit_error402Not enough credit for images or videos. Nothing was charged. Add credit.
library_fullinvalid_request_error409Videos: your media library is full.
not_foundinvalid_request_error404Videos: no such clip, or /content before it completed.
nullinvalid_request_error400Field validation; param names the field.

#Headers and non-errors

  • Authenticated responses carry x-ratelimit-limit and x-ratelimit-remaining, errors included. 401, 405 and unknown-route 404s do not.
  • 405 responses add an allow header. OPTIONS on a route answers 204 with allow, 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.

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).
retry-after429 onlyWhole seconds until a slot opens.

A refused request answers 429 rate_limit_exceeded. Sleep retry-after seconds and retry:

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

Tools & preferences

Make it yours

Changes save automatically