Natiq natiq.studio
Developers Reference Keys

API reference

A REST API for Algerian Darija text-to-speech. Send text, get a WAV back. Submit text and receive a job ID immediately. Poll the job until its audio is ready.

Base URL
https://natiq.studio/api/v1
2 to 13 tokens

per clip, by text length

600 characters

maximum per request

60 req/min

default per key

Create an account

Authentication

Pass your key as a bearer token. Keys look like natiq_live_a1b2c3d4_... and are stored hashed. If you lose one, revoke it and issue another.

Authorization: Bearer natiq_live_a1b2c3d4_xxxxxxxxxxxxxxxxxxxx
Server-side only. This API has no CORS headers on purpose: a key in browser JavaScript is a key anyone can read and spend. Call it from your backend.

All keys use live billing. There is no test-key or sandbox mode. Previously issued test keys have been revoked; create a live key to continue. A read-scoped key can list things but never spends tokens.

POST /speech

Synthesize one clip. Returns when the audio is ready.

ParameterTypeDescription
text requiredstringDarija or Arabic Fusha text, up to 600 characters. Arabic and French may be mixed freely.
voice requiredstringA voice id from GET /voices.
purposestringWhat the clip is for, which changes how it is performed. The list is editable without a deploy, so don't hardcode it - fetch GET /purposes for the current one. Omit it for the default. Use other to send the script without the director or added style, speed or emotion instructions.
countrystringSpeech language: dz for Algerian Darija (default), or msa for Arabic Fusha. This controls pronunciation, not translation. With other, Fusha retains only its language direction.
speedstringHow fast to read it: slow, normal or fast. Named rather than numeric because the speech model takes the pace as an instruction, not as a rate - there is no multiplier in between. Omit it for normal.
Request
curl -X POST https://natiq.studio/api/v1/speech \ -H "Authorization: Bearer $NATIQ_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "مرحباً بكم. المنتج disponible.", "voice": "alg_female_yasmine" }'
Response 202
{ "id": "speech_1842", "object": "speech", "status": "pending", "voice": "alg_female_yasmine", "audio_url": null, "status_url": "https://natiq.studio/api/v1/speech/speech_1842", "duration_seconds": 0, "inference_seconds": 0, "tokens_used": 5, "tokens_remaining": 95, "created_at": "2026-09-14T13:22:07+00:00" }
GET /speech and /speech/{id}

List your clips (newest first) or fetch one. Filter with status; page with limit (max 100) and offset.

curl "https://natiq.studio/api/v1/speech?limit=5&status=success" \ -H "Authorization: Bearer $NATIQ_KEY" # => {"object": "list", "data": [...], "has_more": true}
GET /voices

Every active reference voice. Filter by gender or accent.

idNameGenderAccent
leila ليلى female algerian_arabic
mourad مراد male algerian_arabic
salma سلمى female algerian_arabic
kenza كنزة female algerian_arabic
walid وليد male algerian_arabic
riad رياض male algerian_arabic
GET /purposes

The current purpose list, for the purpose parameter on POST /speech. Purposes are added and retired from the admin without a deploy, so a client should read this rather than hardcode the slugs.

idLabelDefault
commercial_ad إعلان تجاري yes
social_reel ريل لمواقع التواصل
story_telling Story telling
other Other
GET /account

Your balance, this key's usage, and the pricing formula. Poll it to alert yourself before you run dry, and read the formula from here rather than hard-coding a price. See token usage.

{ "object": "account", "tokens_remaining": 1750, "pricing": { "base_tokens": 1, "characters_per_token": 50, ... }, "estimated_clips_remaining": { "characters": 200, "clips": 350 }, "key": { "prefix": "natiq_live_a1b2c3d4", "scope": "write", ... } }

How token usage is calculated

Clips are billed by length. Inference runs for as long as the text is, so a 600 character script costs more than a one line prompt.

The whole formula
tokens = 1 + ceil(characters / 50)

The base covers the fixed cost of a request. Characters are counted after leading and trailing whitespace is trimmed, which is the same text the engine receives. Every character counts, including spaces inside the text, Arabic letters, Latin letters and punctuation.

Text lengthTokens
40 characters 2
100 characters 3
200 characters (a typical clip) 5
400 characters 9
600 characters (the limit) 13

The cheapest possible request is 2 tokens and the most expensive is 13. Text longer than 600 characters is rejected with invalid_request, so you split it yourself and each part is billed on its own length.

CallCostWhy
POST /speech returning 202by lengthTokens are reserved; a failed job is refunded.
POST /speech returning 5020Synthesis failed. The debit is refunded in the same transaction that records the failure.
POST /speech returning 4xx0Rejected before the engine ran.
GET /speech, /voices, /account0Reads are free. They still count against the per-minute rate limit.

Worked example

A catalogue of 40 product descriptions averaging 220 characters each is 40 requests of 1 + ceil(220 / 50) tokens. One 1,500 character article splits into 3 requests of 500 characters, each costing 1 + ceil(500 / 50).

Splitting on sentence boundaries costs slightly more than one long request, because each part pays the base again. Splitting also gives you better control over pacing, so the trade is usually worth it.

Reading your usage

Every successful synthesis returns tokens_used and tokens_remaining, so you can log the real cost of each call. GET /account returns the balance and the formula itself, which is what you should price a batch against rather than hard-coding a number.

curl "https://natiq.studio/api/v1/account" -H "Authorization: Bearer $NATIQ_KEY" # { # "tokens_remaining": 1750, # "pricing": {"base_tokens": 1, "characters_per_token": 50, # "minimum_tokens": 2, "maximum_tokens": 13, "max_characters": 600}, # "estimated_clips_remaining": {"characters": 200, "clips": 350} # }

estimated_clips_remaining is a convenience, not a promise: it says how many clips of 200 characters the balance buys, and names that length so you can redo the sum for your own text.

The per-key totals on your keys page and the line-by-line ledger on your account page record the same numbers server-side, including refunds.

When you run out

Synthesis returns 402 insufficient_tokens and nothing is charged. The message states how many tokens that request needed, so you can decide whether to shorten the text or buy more. Balances do not expire and packs stack. New accounts start with 100 free tokens once the email address is confirmed.

How your text is read

Google receives your script unchanged apart from trimming outer whitespace. English, French, numbers and expression tags such as [laughs] are preserved. Billing uses this submitted length.

The application limit is 600 characters. Cloud TTS also limits each script to 4,000 UTF-8 bytes; Arabic letters typically occupy two bytes. Oversized scripts are rejected before any tokens are reserved. Split longer scripts yourself.

POST /speech returns 202 with status pending and a status_url. GET that URL with your API key every three seconds until status is success or failed. While running, status is processing. Only success contains the finished audio URL. A failed job refunds its reserved tokens. Polling counts toward your API rate limit.

Errors

Every failure returns the same envelope, so you can branch on error.code.

{ "error": { "type": "billing_error", "code": "insufficient_tokens", "message": "Insufficient tokens. You have 2 tokens, but 5 are required.", "doc_url": "/api/docs/" } }
StatuscodeMeaning
400invalid_requestA parameter is missing or malformed. Check error.param.
401missing_credentialsNo key was sent.
401invalid_api_keyUnknown, revoked, or expired key.
402insufficient_tokensBuy more at pricing.
403insufficient_scopeRead-only key tried to synthesize.
403email_unverifiedConfirm the account email address before synthesizing.
404not_foundNo such voice or speech object.
429rate_limit_exceededBack off; see Retry-After.
502engine_errorSynthesis failed. Your tokens were refunded automatically.

A failed generation never costs you anything. The tokens are refunded in the same transaction that records the failure.

Rate limits

60 requests per minute per key by default, with a shared cap of 120 requests per minute across all keys belonging to an account. Creating or rotating keys does not reset the account limit. Successful responses carry X-RateLimit-Limit and X-RateLimit-Remaining; a 429 adds Retry-After in seconds. Reads and polling count too.

An account can have 3 pending or processing clips and submit up to 60 generation attempts per hour. Shared queue capacity and an hourly generation-work budget also apply. A rejected submission returns 429 without charging tokens; wait for Retry-After before submitting again. Short queue-capacity limits return 5 seconds; hourly generation limits return 3600 seconds. Only retry a submission after a definite rejection: a network timeout may hide an accepted job.

Key creation is limited to 10 attempts per hour per account. Revoking keys does not reset this allowance. There is no test-key environment field in the account response; all issued keys use live billing.

Examples

Python
import os, time, requests resp = requests.post( "https://natiq.studio/api/v1/speech", headers={"Authorization": f"Bearer {os.environ['NATIQ_KEY']}"}, json={ "text": "مرحباً بكم في متجرنا", "voice": "alg_female_yasmine", }, timeout=25, ) resp.raise_for_status() clip = resp.json() while clip["status"] in ("pending", "processing"): time.sleep(3) poll = requests.get(f"https://natiq.studio/api/v1/speech/{clip['id']}", headers={"Authorization": f"Bearer {os.environ['NATIQ_KEY']}"}, timeout=15) if poll.status_code == 429: time.sleep(min(3600, max(1, int(poll.headers.get("Retry-After", "60"))))) continue poll.raise_for_status() clip = poll.json() if clip["status"] != "success": raise RuntimeError(clip.get("error", "Generation failed")) audio = requests.get(clip["audio_url"], timeout=60).content open("out.wav", "wb").write(audio)
Node.js
const res = await fetch("https://natiq.studio/api/v1/speech", { method: "POST", headers: { Authorization: `Bearer ${process.env.NATIQ_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ text: "مرحباً بكم", voice: "alg_female_yasmine", }), }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${error.code}: ${error.message}`); } let clip = await res.json(); while (["pending", "processing"].includes(clip.status)) { await new Promise(resolve => setTimeout(resolve, 3000)); const poll = await fetch(`https://natiq.studio/api/v1/speech/${clip.id}`, { headers: {Authorization: `Bearer ${process.env.NATIQ_KEY}`} }); if (poll.status === 429) { const delay = Math.min(3600, Math.max(1, Number(poll.headers.get("Retry-After") || 60))); await new Promise(resolve => setTimeout(resolve, delay * 1000)); continue; } if (!poll.ok) throw new Error("Status request failed"); clip = await poll.json(); } if (clip.status !== "success") throw new Error(clip.error);
PHP
$ch = curl_init("https://natiq.studio/api/v1/speech"); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("NATIQ_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "text" => "مرحباً بكم", "voice" => "alg_female_yasmine", ], JSON_UNESCAPED_UNICODE), ]); $body = curl_exec($ch); if (curl_getinfo($ch, CURLINFO_HTTP_CODE) !== 202) { throw new RuntimeException('Submission rejected; inspect the response and Retry-After before retrying'); } curl_close($ch); $clip = json_decode($body, true); while (in_array($clip['status'], ['pending', 'processing'])) { sleep(3); $poll = curl_init('https://natiq.studio/api/v1/speech/' . $clip['id']); $retryAfter = 60; curl_setopt_array($poll, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15, CURLOPT_HEADERFUNCTION => function ($ch, $header) use (&$retryAfter) { if (stripos($header, 'Retry-After:') === 0) $retryAfter = (int) trim(substr($header, 12)); return strlen($header); }, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('NATIQ_KEY')]]); $body = curl_exec($poll); if (curl_getinfo($poll, CURLINFO_HTTP_CODE) === 429) { curl_close($poll); sleep(min(3600, max(1, $retryAfter))); continue; } if (curl_getinfo($poll, CURLINFO_HTTP_CODE) !== 200) { throw new RuntimeException('Status request failed'); } $clip = json_decode($body, true); curl_close($poll); } if ($clip['status'] !== 'success') throw new RuntimeException($clip['error']);