API reference

Two endpoints, one header.

Authenticate with a bearer key from your dashboard. Everything is multipart form data — no SDK, no client library, nothing to keep updated.

Base URL
https://nutq.dev
POST/v1/transcribe

Transcribe audio

Send a recording, get the text. Long files are split and stitched for you. Dialect is preserved rather than rewritten into Modern Standard Arabic.

Fields
filefilerequired
Audio or video, any common format, up to 100 MB and one hour.
languagestring
Defaults to auto, which works it out from the audio. Name one — ar, en, fr, es, de, it, pt, nl, pl, el, ja, ko, zh, vi — and it is used as given, unless the recording is clearly a different language, in which case we transcribe what was actually spoken and say so in language_source. Detection needs about two seconds of speech; below that a named language is used unchanged and auto falls back to ar. Arabic is tuned for Gulf and Levantine speech; Maghrebi, Yemeni and Sudanese are recognised but less accurately.
curl -X POST https://nutq.dev/v1/transcribe \
  -H "Authorization: Bearer $NUTQ_API_KEY" \
  -F "file=@call.mp3"
Response
{
  "text": "هلا وغلا فيك، شحالك اليوم؟",
  "language": "ar",
  "language_source": "detected",
  "language_confidence": 1,
  "usage": {
    "audio_seconds": 13.23,
    "cost_usd": 0.00056
  },
  "model": "nutq-listen-1",
  "profanity": { "hits": [] }
}

language is what the audio was actually transcribed in, which is not always what you asked for. language_source says how it was decided: requested (yours, used as-is), detected (you sent auto), corrected (you named a language and the recording was clearly another one), or fallback (too short to tell, so ar). usage is what this request cost you, not an estimate. model names the engine that served the request, so a response can be traced back to a version. Profanity is masked in text — its first letter kept, the rest asterisks, so the length never changes — and profanity.hits says where: start and end, counted in Unicode code points, with the rule that matched or, for a sentence our classifier judged abusive and masked whole, its score. The words themselves are never returned.

POST/v1/speech

Generate speech

Send text and a voice name. Arabic clones, so a voice is a reference recording — ours are ready to use by name, and you can send your own instead. Other languages use trained voices. Returns a WAV; usage travels in the response headers.

Fields
textstringrequired
Up to 5,000 characters, in the language you asked for.
languagestring
Defaults to ar. Also accepts en, en-gb, es, fr, hi, it, ja, pt, zh.
voicestringrequired
For Arabic, an id or name from GET /v1/voices — either one of ours or a voice saved on your account, which wins if the names collide. For other languages, a trained voice such as af_heart or bm_george, and optional.
referencefile
Arabic only, and an alternative to voice: the recording to clone when it is a voice we do not have. Six to eight seconds is the sweet spot. Longer is trimmed to about eight and re-transcribed, which costs a round trip; over twenty is rejected. Needs multipart.
ref_textstring
Required alongside reference. What that recording says — the model aligns against it.
modelstring
Arabic only. nutq-speak-3 (the default) is the newest and most expressive model; nutq-speak-2 is the expressive model before it, and nutq-speak-1 the original without emotions — both kept so you can pin them. Egyptian voices are always read by the Egyptian model.
emotionstring
nutq-speak-3 and nutq-speak-2: amused, excited, angry, sad, scared, tender or surprised. Leave it out for neutral. To change emotion inside the text, write the cue where it starts — [sad] … [excited] … — and [sigh] for a sigh. nutq-speak-3 also laughs where you write [laughter].
nfe_stepinteger
nutq-speak-1 only. Quality against speed, 8–64. Defaults to 32; 64 is noticeably cleaner and about twice as slow. Higher values are treated as 64.
speedfloat
Playback rate of the generated speech. Defaults to 1.
streamboolean
nutq-speak-3 and nutq-speak-2. Send true to receive raw audio as it is generated, a sentence at a time, instead of one WAV at the end: headerless 16-bit little-endian mono PCM (Content-Type audio/L16). The model you name speaks the stream, and X-Nutq-Model says which. The first audio arrives in about two seconds on a short line; nutq-speak-2 is about 0.3 seconds quicker than nutq-speak-3 if every moment counts. nutq-speak-1 answers with a WAV as usual; check the Content-Type.
sample_rateinteger
With stream: 8000, 16000, 22050 or 24000 (the default). Telephony usually wants 8000 or 16000.
curl -X POST https://nutq.dev/v1/speech \
  -H "Authorization: Bearer $NUTQ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"هلا وغلا فيك، شحالك اليوم؟","voice":"hind"}' \
  --output speech.wav
Response
HTTP/1.1 200 OK
Content-Type: audio/wav
X-Nutq-Characters: 57
X-Nutq-Audio-Seconds: 5.672
X-Nutq-Cost-USD: 0.000285
X-Nutq-Model: nutq-speak-3

The body is the audio itself, so usage is returned in headers. Text with profanity or abuse in it is not spoken: the answer is a 422 with code "profanity" and hits saying where, as start and end in the text you sent, and nothing is billed — streamed or not.

Fixing a word that comes out wrong

Arabic is written without vowels and read from context, so a word with two readings is a guess — تعد is either تَعُدّ or تُعَدّ, and درس is either دَرَسَ or دَرَّسَ. Add the marks to that one word and it will be read the way you wrote it.

  • تُعَدُّ دولة الإمارات من أبرز الدولOne word marked — reads correctly, sounds natural.
  • تُعَدُّ دَوْلَةُ الْإِمَارَاتِ مِنْ أَبْرَزِ الدُّوَلِEvery word marked — correct, but slower and more formal, like a recitation. Avoid unless that is what you want.

Mark only what is ambiguous, which is how Arabic is normally written. Marking a whole sentence changes the delivery, not just the vowels: it runs about half again as long and reads like a recital rather than speech. Automatic diacritizers are not recommended — we tested them and they introduce errors of their own, especially in dialect.

Several voices, and pauses

Open a line with a voice name in square brackets and that voice speaks until the next one. Insert [pause] anywhere for a gap, with a length in milliseconds or seconds if you want a specific one. Everything comes back as a single file.

  • [Rashid] شحالك؟ [Noura] بخير الحمد للهTwo library voices in one generation. The name must open the line.
  • أهلا بكم [pause:800] ونبدأA gap of exactly 800 ms. [pause] on its own is 500 ms, [pause:2s] is two seconds.

Each line is generated on its own and the pieces are joined, so the second speaker does not react to the first — there is no overlap and no answering intonation. It reads as two people reading lines rather than as a conversation. Brackets elsewhere in a line are left alone, so a citation is not mistaken for a speaker; only a name at the start of a line counts. Silence is not billed.

POST/v1/voices

Save a voice

Store a recording so you can speak with it by name instead of uploading it again. Multipart, because it carries a file. Returns the id to pass as "voice". Twenty per account.

Fields
namestringrequired
What you will call it. Yours win over ours if the name collides.
audiofilerequired
The recording. Six to eight seconds is the sweet spot; three to twenty is accepted. WAV is checked for length, other formats are stored as sent.
ref_textstringrequired
What the recording says — the model aligns against it.
consentbooleanrequired
Confirm you have the right to clone this voice. This stores a recording of a person, so it is asked rather than assumed.
curl -X POST https://nutq.dev/v1/voices \
  -H "Authorization: Bearer $NUTQ_API_KEY" \
  -F "name=Layla" \
  -F "audio=@layla.wav" \
  -F "ref_text=نص التسجيل المرجعي" \
  -F "consent=true"
Response
HTTP/1.1 201 Created
Content-Type: application/json

{"id":"6f0c…","name":"Layla","seconds":7.35,"ref_text":"…","created_at":"…"}
DELETE/v1/voices/{id}

Delete a voice

Remove a saved voice and the recording behind it. The audio goes first, so a failure leaves something you can still find rather than orphaned audio of a person.

Fields
curl -X DELETE https://nutq.dev/v1/voices/VOICE_ID \
  -H "Authorization: Bearer $NUTQ_API_KEY"
Response
HTTP/1.1 200 OK

{"deleted":"6f0c…"}

Acting for someone else

If you are building something other people install — a panel, an extension, a desktop app — do not ask them to paste an API key. Use the device flow: they approve your application by name, see exactly what it may do, and can disconnect it later without touching their own keys.

Scopes are transcribe, speak and read. Ask only for what you use — a token is refused with insufficient_scope for anything else. Requests are billed to the person who approved you.

  1. 1Ask for a code

    Your application posts its client id and the scopes it needs. It gets back a short code to show the person, and a device code it keeps to itself.

    curl -X POST https://nutq.dev/oauth/device/code \
      -F "client_id=your-client-id" \
      -F "scope=transcribe speak read"
    
    { "user_code": "K7M2-QP4X",
      "device_code": "…",
      "verification_uri": "https://nutq.dev/oauth/device",
      "expires_in": 600, "interval": 5 }
  2. 2Show it to the person

    Display the user code and the verification link. They open it in a browser where they are already signed in, see your application by name and what it is asking for, and approve or decline.

    Show: "Go to nutq.dev/oauth/device and enter K7M2-QP4X"
  3. 3Poll for the token

    Poll no faster than the interval you were given. You will get authorization_pending until they decide, slow_down if you poll too fast, access_denied if they decline, and expired_token after ten minutes. On approval you get an access token good for an hour and a refresh token.

    curl -X POST https://nutq.dev/oauth/token \
      -F "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
      -F "client_id=your-client-id" \
      -F "device_code=$DEVICE_CODE"
  4. 4Renew before it expires

    Refresh tokens rotate: each exchange returns a new pair and retires the old one. Reusing a spent refresh token fails, which is how you find out a copy of it is loose rather than sharing access silently.

    curl -X POST https://nutq.dev/oauth/token \
      -F "grant_type=refresh_token" \
      -F "client_id=your-client-id" \
      -F "refresh_token=$REFRESH_TOKEN"

Reading

Two GET endpoints for clients that need to know what they can speak with and what is left to spend. Both take the same bearer key.

GET/v1/voicesList voices

Every id here is a valid voice for POST /v1/speech. Three lists, because they differ: our Arabic voices, the ones saved on your account, and the trained voices for other languages. A name saved on your account wins over ours. Sample URLs are signed and expire within the hour — they are for letting someone hear a voice, not for feeding back into synthesis, so fetch this when you need it rather than caching it for a day.

curl https://nutq.dev/v1/voices \
  -H "Authorization: Bearer $NUTQ_API_KEY"
GET/v1/accountAccount and balance

The email this key belongs to, the balance remaining, and totals for what it has used. Useful for a client that would otherwise have to infer the balance from its own request history.

curl https://nutq.dev/v1/account \
  -H "Authorization: Bearer $NUTQ_API_KEY"

Every endpoint under /v1 allows cross-origin requests, so a desktop panel or browser extension can call it directly. Keys travel in a header, never a cookie — which is what makes that safe.

AI agents (MCP)

Claude, ChatGPT, Cursor, Claude Code, Antigravity and other AI apps connect to nutq as an MCP server. They sign the person in through nutq — no key to copy — and can then transcribe recordings and videos and speak scripts with emotions, laughter, sighs, pauses and several voices, billed to that account. Step-by-step setup for each app, the skills, and the list of connected apps are on the Connect AI page.

Server URL
https://nutq.dev/mcp
  • Streamable HTTP, MCP 2025-06-18 through 2026-07-28. OAuth 2.1 with PKCE, dynamic client registration and client ID metadata documents; discovery at /.well-known/oauth-protected-resource/mcp.
  • A personal key works too, as Authorization: Bearer nutq_sk_… — for clients whose sign-in is unreliable or that only take a header.
  • Tools: generate_speech, check_speech_script, transcribe_url, create_upload_link, get_job, list_voices, get_speech_guide, get_account. Long work hands back a job id rather than timing out.
  • Tokens issued for /mcp are refused by /v1, and the other way round: each is good only for the audience it was issued to.

Voice agents

Agent platforms call you rather than the other way round: streaming the caller in, and asking for audio back fast enough to hold a conversation. Nutq can take both ends — hearing Gulf Arabic and answering in it — while the platform keeps the model and the phone line.

Vapi

Both halves of a call can run on nutq: it hears the caller and it answers them. Each is one block of assistant configuration — nothing to install, no callback to host, and every request billed to the key you put in it. Use either on its own if you only want one.

Speaking

Vapi posts each line its model wants said and plays back what you return. Your key travels in the header it sends with every request.

"voice": {
  "provider": "custom-voice",
  "server": {
    "url": "https://nutq.dev/v1/vapi/voice?voice=rashid",
    "headers": { "Authorization": "Bearer $NUTQ_API_KEY" },
    "timeoutSeconds": 45
  }
}
Listening

Vapi opens a socket and streams the call through it, and transcripts come back as each turn ends. Check first that your Vapi account accepts custom-transcriber: some reject it at publish time with a list of providers that does not include it, in which case only the speaking half can be used there. A WebSocket handshake carries no headers you control, so the key goes on the URL — use a second key here, not the one above, so this one can be revoked on its own if a log ever leaks it.

"transcriber": {
  "provider": "custom-transcriber",
  "server": {
    "url": "wss://worker.nutq.dev/v1/transcribe/stream?key=$NUTQ_STREAM_KEY&language=auto",
    "timeoutSeconds": 30
  }
}
  • Settings ride on the URL because Vapi owns the request body. Speaking takes voice to name a Gulf voice, model (nutq-speak-3 by default; nutq-speak-2 answers about 0.3 seconds sooner), language for the other engine, and nfe_step, which defaults to 16 rather than the studio’s 32 — in a call the extra steps are less audible than the wait. Listening takes language, which defaults to auto: the first turn long enough to judge sets the call’s language and the short ones inherit it, so a caller who answers “نعم” is not re-guessed on one word.
  • Whichever sample rate Vapi asks for is answered exactly: 8000, 16000, 22050 or 24000 Hz, mono, raw 16-bit PCM.
  • Keep replies short in your system prompt. Each is synthesised in full before the caller hears any of it, so a long answer is a long silence — one or two sentences is the difference between a conversation and a hold.
  • A turn ends after about 700ms of quiet, and anything past 15 seconds is transcribed anyway rather than waiting for a pause that may not come. Mid-sentence pauses are normal in speech and do not split a turn.
  • Deepgram, the default transcriber on most voice platforms, has no Arabic model at all: nova-2 and nova-3 reject ar outright and only multi accepts it, which mistakes Gulf Arabic for other languages entirely. This is the gap the listening half is for. Where a custom transcriber is not available, openai with gpt-4o-transcribe and language ar is the closest thing that works everywhere.

Live transcription

Open a socket, push raw audio, read transcripts back as they finish. It speaks Deepgram’s live protocol on purpose: if you already have a Deepgram client, point it here and change nothing else. Billed per second of audio, like the upload endpoint.

wss://worker.nutq.dev/v1/listen
Query parameters
encoding string
linear16 — 16-bit little-endian PCM. It is the only encoding we accept, and anything else is refused at connect time rather than played back as noise.
sample_rate number
Defaults to 16000. 8000 to 48000.
channels number
Defaults to 1. Send what you are actually sending — announcing one channel and sending two transcribes every other sample.
channel number
Which interleaved channel carries the person you want transcribed. Defaults to 0. Ours, not Deepgram’s: a call recorder often has the far end on one channel and the room on the other.
language string
Defaults to auto. The first stretch of speech long enough to judge sets the language for the socket and short turns inherit it, so a caller answering “نعم” is not re-guessed on one word.
model string
Accepted and ignored. There is one.
Authentication

Authorization: Token <key> is what a Deepgram client already sends, and it works. Bearer works too, and so does ?key= on the URL for platforms that will not let you set a header on a handshake — that one reaches access logs, so give it its own key.

What to send
Binary frames
Raw PCM at the rate and channel count you declared. Any frame size; nothing is lost at the boundaries.
{"type":"KeepAlive"}
Accepted, and does nothing — the socket is not closed for being quiet.
{"type":"Finalize"}
Return whatever is buffered now. Send this when you stop sending audio mid-sentence, or the last words wait for a pause that is never coming.
{"type":"CloseStream"}
Finish and close. You get a Metadata frame with the billed duration.
What comes back
{
  "type": "Results",
  "start": 14.30,
  "duration": 14.70,
  "is_final": true,
  "speech_final": true,
  "channel": {
    "alternatives": [{
      "transcript": "تعلمت إن مو لازم كل شي يصير بسرعة",
      "confidence": null,
      "words": [],
      "language": "ar"
    }]
  },
  "metadata": { "request_id": "7024d046-…", "model_info": { "name": "nutq-listen-1" } }
}
What it does not do
  • No interim results. Every result is final. Our recogniser takes a clip and returns text rather than streaming hypotheses, so a transcript exists once an utterance has ended — about 700 ms after someone stops talking. If your interface shows words appearing as they are spoken, this will not do that.
  • confidence is null, not a number. The model produces no calibrated score and we would rather omit one than invent a figure somebody thresholds on.
  • words is always empty. The checkpoint emits no word-level timings. start and duration on each result are real, so you can still jump to a moment.
  • A stretch of speech with no pause in it is cut at about fifteen seconds, at the quietest point we can find near that mark rather than on the clock. Somebody talking without pausing therefore gets a result roughly every fifteen seconds, not continuously.
Python
import asyncio, json, websockets

URL = ("wss://worker.nutq.dev/v1/listen"
       "?encoding=linear16&sample_rate=16000&channels=1&language=auto")

async def main(pcm_frames):
    async with websockets.connect(
        URL, additional_headers={"Authorization": f"Token {NUTQ_API_KEY}"}
    ) as ws:
        async def send():
            for frame in pcm_frames:      # 16-bit LE mono, any size
                await ws.send(frame)
            await ws.send(json.dumps({"type": "CloseStream"}))

        async def read():
            async for raw in ws:
                m = json.loads(raw)
                if m["type"] == "Results":
                    said = m["channel"]["alternatives"][0]["transcript"]
                    print(f'[{m["start"]:.2f}s] {said}')
                elif m["type"] == "Metadata":
                    return

        await asyncio.gather(send(), read())

Models

Two of them. Every response names the one that served it, so output can be traced back to a version — and when a model is retired the name keeps working, pointed at its successor.

nutq-listen-1Speech to textGulf and Levantine Arabic at dialect level, plus thirteen other languages. Keeps what was said rather than rewriting it into Modern Standard Arabic. Specialised for Gulf speech, which costs it some accuracy on Maghrebi and southern dialects.
nutq-speak-3Most expressive Arabic speechThe default for Gulf voices. Everything nutq-speak-2 does, with a wider emotional range, real laughter where you write [laughter], and steadier long reads for ads, audiobooks and drama. Takes the same emotions and cues, whole files and streams alike.
nutq-speak-2Expressive Arabic speechThe expressive model before nutq-speak-3, and the one that streams. Clones from a few seconds of reference audio without needing its transcript, takes an emotion — amused, excited, angry, sad, scared, tender, surprised — and reads cues written in the text, such as [sad] or [sigh]. Reads English words inside Arabic as English.
nutq-speak-1Arabic speechGulf Arabic, cloned from a few seconds of reference audio. Trained on Emirati and Saudi speech, so the accent is Gulf rather than broadcast MSA.
nutq-voice-1Other languagesEnglish, Spanish, French, Italian, Portuguese, Hindi, Japanese and Chinese, from trained voices rather than cloning. Here so a bilingual product does not need a second vendor for its English line.

When things fail

Standard HTTP codes, and a message that names the problem. A request that fails is never billed.

401The key is missing, malformed, or revoked.Check the Authorization header reads "Bearer nutq_sk_…".
402The balance is spent.Top up, then retry the same request.
400A field is missing or wrong.The message names the field.
413The upload is too large.Under 100 MB, one hour of audio, 5,000 characters of text.
422The text has profanity or abuse we will not speak (code "profanity").hits gives each place as start and end in your text. Reword those parts; you were not billed.
503Inference is unavailable.Ours, not yours. Retry shortly; you were not billed.