Streaming

There are five flavours of "stream" in the Hakim API:

  1. Chunked TTS audio (HTTP). POST /v1/audio/speech with "stream": true returns the synthesised audio as a chunked HTTP response, so playback can start before the whole sentence is finished. Same endpoint as the synchronous call; the body field is the only switch.
  2. Realtime TTS (WebSocket). GET /v1/audio/speech/stream upgrades to a long-lived WebSocket and lets you push many speech.create frames over a single TCP/TLS connection. Trades the ~7 ms per-request handshake cost for a persistent socket; pairs naturally with LLM-→-TTS pipelining (synthesise the next sentence while the LLM is still generating tokens).
  3. Realtime STT (OpenAI-compatible WebSocket). GET /v1/audio/transcriptions/stream upgrades to a long-lived WebSocket. Push microphone frames in, receive partial + final transcripts as they're decoded. Drop-in compatible with the OpenAI Realtime client SDKs; Arabic-first acoustic model (hakim-arab-v2).
  4. Realtime translation (WebSocket). GET /v1/audio/translate/stream chains STT → LLM → TTS behind one socket. Push microphone frames in, receive interim transcripts, translated text, and translated audio per utterance. Same auth, billing, and reconnect semantics as the other realtime endpoints.
  5. Async results from long uploads. large STT files (> 25 MiB or > 10 minutes) and voice clones return a job pointer. Track completion via /v1/jobs/{id} or a webhook.

The canonical contract for every endpoint below (request shape, response codes, all enum values) lives at api.tryhakim.ai/docs (interactive Scalar reference) and api.tryhakim.ai/openapi.json (machine-readable). When this guide and the OpenAPI disagree, the OpenAPI wins.

Streaming TTS

There is no separate /stream endpoint for TTS. Streaming is a flag on the standard POST /v1/audio/speech route: set "stream": true in the request body and the server replies with Transfer-Encoding: chunked, beginning to write audio as soon as the first frame is ready (~150 ms target on the Hakim Fast v1.3 profile).

curl -N https://api.tryhakim.ai/v1/audio/speech \
  -H "Authorization: Bearer $HAKIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "hakim-fast-v1",
    "input": "مرحبا، كيف حالك اليوم؟",
    "voice": "cmokbc2b1001pvu39wmj61b7h",
    "response_format": "mp3",
    "stream": true
  }' \
  --output - \
  | ffplay -autoexit -nodisp -

The -N flag disables curl's response buffering so audio starts playing as soon as the first frame arrives.

The voice field accepts a voice id (cuid form, recommended — stable across renames) returned by GET /v1/audio/voices. Models:

  • hakim-fast-v1 (Saree') · speed tier, the default. Streaming TTFB lands around 113 ms p95; use it for voicebots, IVR, in-game characters, anywhere the latency budget is the binding constraint.
  • hakim-v2 (Faseeh) · premium quality with non-verbal tag support; also streams, but the marketing pitch is naturalness, not speed.
  • hakim-v3 (Mulhim) · premium quality + voice design from a free-form prompt; streams via the same flag.

Streaming is supported on every tier. The first-chunk arrival time is a tier-level promise only for Saree'; Faseeh / Mulhim ship without a published baseline because their pitch is quality, not speed. See the models guide for the full ladder and the tier-picker recipe.

Node.js

The official @hakim/node SDK exposes streaming as audio.speech.stream(...) (returns an AsyncIterable<Uint8Array>) in addition to the buffered audio.speech.create(...) (returns the full body):

import { HakimClient } from '@hakim/node';

const hakim = new HakimClient({ apiKey: process.env.HAKIM_API_KEY });

const { stream, contentType } = await hakim.audio.speech.stream({
  model: 'hakim-fast-v1',
  input: 'مرحبا، كيف حالك اليوم؟',
  voice: 'cmokbc2b1001pvu39wmj61b7h',
  response_format: 'mp3',
});

console.log('streaming', contentType);
for await (const chunk of stream) {
  // Hand each chunk to your audio subsystem (Web Audio decoder,
  // WhatsApp Voice Note encoder, on-disk file, etc.) as soon as it
  // arrives. buffering defeats the purpose of streaming.
  process.stdout.write(chunk);
}

The SDK forces stream: true on the wire when you call .stream() even if the caller passes stream: false, so the request body and the method name can never disagree.

Browsers

In a browser, pipe the ReadableStream into a MediaSource for progressive playback. Never call the public API from a browser directly. your API key is a server credential. The example below assumes a server-side proxy at /api/proxy/hakim/* that forwards to api.tryhakim.ai with the bearer token attached.

const res = await fetch('/api/proxy/hakim/audio/speech', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: 'hakim-fast-v1',
    input,
    voice,
    response_format: 'mp3',
    stream: true,
  }),
});
if (!res.body) throw new Error('No stream body');

const mediaSource = new MediaSource();
const audio = new Audio(URL.createObjectURL(mediaSource));
audio.play();

mediaSource.addEventListener('sourceopen', async () => {
  const sourceBuffer = mediaSource.addSourceBuffer('audio/mpeg');
  const reader = res.body!.getReader();
  const pump = async (): Promise<void> => {
    const { done, value } = await reader.read();
    if (done) {
      mediaSource.endOfStream();
      return;
    }
    await appendBuffer(sourceBuffer, value);
    return pump();
  };
  await pump();
});

function appendBuffer(sb: SourceBuffer, data: Uint8Array) {
  return new Promise<void>((resolve) => {
    sb.addEventListener('updateend', () => resolve(), { once: true });
    sb.appendBuffer(data);
  });
}

Format notes

  • mp3 (default): widest client compatibility; recommended for most browser playback paths.
  • opus: lowest latency and smallest bytes-on-wire; requires a decoder (<audio> supports Opus in WebM since 2018).
  • wav: uncompressed, ideal when the consumer is a DSP / ASR pipeline that doesn't want to decode MP3 on each chunk.
  • pcm: raw 16-bit signed little-endian samples at the sample_rate you requested (default 24 kHz). Use when feeding a vocoder, telephony bridge, or another model.

Chunk size is shaped to emit the first frame within ~150 ms of the model's first token. You'll typically see 4–8 chunks per second during steady-state synthesis.

Realtime TTS (WebSocket)

For sessions that need to synthesise many utterances back-to-back (LLM-→-TTS pipelining, embedded agents, voice widgets that make dozens of calls per page-view) the HTTP path pays a fresh TCP/TLS/HTTP-header handshake on every call (~7 ms cross-region). The WebSocket surface trades that per-request cost for a long-lived socket: open it once, send speech.create frames as the LLM emits sentences, receive raw PCM-S16LE chunks (24 kHz mono) interleaved with lifecycle events. The companion endpoint is

wss://api.tryhakim.ai/v1/audio/speech/stream

It runs the same auth + scope + quota chain as POST /v1/audio/speech and bills the same character meter. Units flush to UsageEvent('tts') every 30 s + on close, so a crashed session still bills up to the last tick. For one-shot synthesis (a single sentence) the chunked HTTP path above is simpler; the WebSocket shines when there's more than one utterance per session.

Format constraints

  • response_format is locked to pcm for v1. If you need mp3, opus, wav, aac, or flac, use the HTTP path.
  • sample_rate is locked to 24000 (engine-native). Re-sample client-side if you need 16/22/44/48 kHz output.

These constraints exist because the proxy forwards raw engine output bytes through the socket. Anything else would require a server-side transcoder that defeats the latency story.

Message protocol

Client → server (JSON text frames):

typePurpose
session.updatePin session-wide defaults (model, voice, cfg, voice_prompt, chunk_chain, trailing_silence_ms).
speech.createRequest one utterance. request_id is optional and echoed back.
session.closeTear down the session cleanly.

Server → client (mix of JSON text + binary):

typePurpose
session.createdSent once after the upgrade; carries the merged config + limits.
speech.startedEngine has begun streaming for the matching request_id.
(binary)Raw PCM-S16LE bytes for the in-flight utterance. Reassemble in order.
speech.doneTerminal per-utterance frame with the OpenAI-shaped usage block.
session.usage30 s heartbeat with cumulative session_characters billed so far.
errorServer-side failure. fatal: true ⇒ the session is closing.

Session options for chunked text

Two opt-in session.update options help clients that send their text as many small speech.create chunks, for example when forwarding an LLM's output sentence by sentence. Both are off by default; a session that does not set them behaves exactly as before.

OptionDefaultWhat it does
chunk_chainengine defaultBy default the engine continues each utterance from the one it just generated. That keeps a voice consistent, but over a long session it can flatten delivery and let the level drift. Set false to generate every utterance from the voice's own reference instead. A speech.create can override it for a single utterance.
trailing_silence_ms100Silence appended after every utterance, from 0 to 500. When text arrives in chunks you hear it as a gap between them: set 0 for gapless joins. speech.done.duration_ms includes whatever pad is applied.
{
  "type": "session.update",
  "session": {
    "model": "hakim-v3",
    "voice": "YOUR_VOICE_ID",
    "chunk_chain": false,
    "trailing_silence_ms": 0
  }
}

Try chunk_chain: false when long sessions start to sound flat or the volume creeps up from one utterance to the next. Both options work on every model tier, including the premium Faseeh and Mulhim tiers.

Node SDK

The official @tryhakim/voice SDK exposes the surface as audio.speech.streamWs(...). Iterate events for lifecycle frames

  • audio chunks, or iterate audio for raw PCM only:
import { Hakim } from '@tryhakim/voice';

const hakim = new Hakim({ apiKey: process.env.HAKIM_API_KEY });

const session = hakim.audio.speech.streamWs({
  model: 'hakim-fast-v1',
  voice: 'cmokbc2b1001pvu39wmj61b7h',
});

session.sendSpeech({ input: 'مرحبا، كيف حالك اليوم؟' });
session.sendSpeech({ input: 'تشرفت بلقائك.' });

for await (const ev of session.events) {
  if (ev.type === 'speech.audio') speaker.write(ev.chunk);
  if (ev.type === 'speech.done') console.log('billed', ev.usage.units, 'chars');
  if (ev.type === 'session.usage') console.log('session total', ev.session_characters);
  if (ev.type === 'error' && ev.fatal) throw new Error(ev.message);
}

await session.close();

Python SDK

The Python SDK ships the matching audio.speech.stream_ws(...) helper:

from hakim import AsyncHakim

async with AsyncHakim(api_key=...).audio.speech.stream_ws(
    model="hakim-fast-v1",
    voice="cmokbc2b1001pvu39wmj61b7h",
) as session:
    await session.send_speech({"input": "مرحبا، كيف حالك اليوم؟"})
    async for ev in session:
        if ev["type"] == "speech.audio":
            speaker.write(ev["chunk"])
        elif ev["type"] == "speech.done":
            break
        elif ev["type"] == "error" and ev.get("fatal"):
            raise RuntimeError(ev["message"])

Raw WebSocket

The wire is plain enough that any WebSocket client works. Useful when you're driving the socket from a language without an official SDK (Go, Rust, browser without the proxy):

import { WebSocket } from 'ws';

const ws = new WebSocket('wss://api.tryhakim.ai/v1/audio/speech/stream', {
  headers: { Authorization: `Bearer ${process.env.HAKIM_API_KEY}` },
});

ws.on('open', () => {
  ws.send(
    JSON.stringify({
      type: 'session.update',
      session: { model: 'hakim-fast-v1', voice: 'cmokbc2b1001pvu39wmj61b7h' },
    }),
  );
  ws.send(JSON.stringify({ type: 'speech.create', input: 'مرحبا بالعالم' }));
});

ws.on('message', (data, isBinary) => {
  if (isBinary) {
    // Raw PCM-S16LE bytes for the in-flight utterance.
    speaker.write(data);
    return;
  }
  const ev = JSON.parse(data.toString());
  if (ev.type === 'speech.done') ws.close();
  if (ev.type === 'error') console.error(ev.code, ev.message);
});

The connection requires the tts:write scope on the API key. Same per-IP session-open envelope as the STT realtime path applies; see the rate-limits guide.

Realtime STT (WebSocket)

Open a WebSocket against wss://api.tryhakim.ai/v1/audio/transcriptions/stream, push audio frames as they arrive from the mic, and consume transcription.delta events as the model decodes them. The wire format follows the OpenAI Realtime event contract, so existing realtime clients can point at Hakim by swapping the base URL and bearer token, no protocol shim required.

wscat \
  -H "Authorization: Bearer $HAKIM_API_KEY" \
  --connect wss://api.tryhakim.ai/v1/audio/transcriptions/stream

The connection requires the stt:write scope on the API key. Per-IP rate limiting fires on session open, not per audio frame, so a chatty session won't trip the quota. But a flood of new sessions will. See the rate-limits guide for the session-open envelope.

Message protocol

Client → server:

typePurpose
session.updateConfigure language, model, partial-segment cadence (optional).
input_audio_buffer.appendAppend a base64-encoded PCM 16-bit mono 16 kHz frame.
input_audio_buffer.commitMark end-of-utterance; flush any buffered audio for final decode.
session.closeTear down the session cleanly. Server replies and closes.

Server → client:

typePurpose
session.createdSent once on connect with the assigned session_id.
transcription.deltaPartial transcript fragment as audio is decoded.
transcription.doneFinal transcript for the most recent commit.
errorRecoverable error (rate limit, decode failure). Session OK.

A minimal Node example using a plain WebSocket (drop-in for the OpenAI Realtime SDK shape):

import { WebSocket } from 'ws';

const ws = new WebSocket('wss://api.tryhakim.ai/v1/audio/transcriptions/stream', {
  headers: { Authorization: `Bearer ${process.env.HAKIM_API_KEY}` },
});

ws.on('open', () => {
  // `hakim-arab-v2` is the only STT model id · pass it explicitly
  // so the wire payload is unambiguous even if the default changes.
  ws.send(
    JSON.stringify({
      type: 'session.update',
      session: { model: 'hakim-arab-v2', language: 'ar' },
    }),
  );
});

ws.on('message', (raw) => {
  const event = JSON.parse(raw.toString());
  switch (event.type) {
    case 'session.created':
      // Echoed once after the upgrade succeeds. `event.session` is
      // the merged config + defaults; `event.limits` reports the
      // per-session size + duration caps.
      break;
    case 'transcription.delta':
      // Partial transcript fragment. `event.is_final === false`
      // means the text may still change on the next delta.
      process.stdout.write(event.text);
      break;
    case 'transcription.done':
      console.log('\nFINAL:', event.text);
      break;
    case 'error':
      // `event.code` is the stable identifier to switch on
      // (e.g. `upstream_error`, `frame_too_large`, `session_too_long`);
      // `event.message` is a human-readable hint.
      console.error('stream error', event.code, event.message);
      break;
  }
});

// Push 20 ms PCM frames as they arrive from your mic capture loop.
// The audio payload is **base64-encoded** inside the JSON frame —
// the server rejects binary WebSocket frames with `binary_frames_not_supported`.
function pushFrame(pcm16: Buffer) {
  ws.send(
    JSON.stringify({
      type: 'input_audio_buffer.append',
      audio: pcm16.toString('base64'),
    }),
  );
}

// When the speaker pauses, send `input_audio_buffer.commit` to flush
// the current buffer · the next `transcription.done` carries the
// stitched final transcript for that window.
function endOfUtterance() {
  ws.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
}

Custom vocabulary

Pass a vocabulary object inside session.update to bias recognition toward domain-specific words, pin unusual spellings, and give the model broader topical context, useful for product names, acronyms, and people's names that a general-purpose acoustic model wouldn't otherwise favour.

ws.send(
  JSON.stringify({
    type: 'session.update',
    session: {
      model: 'hakim-arab-v2',
      language: 'ar',
      vocabulary: {
        terms: ['هاكِم', 'Hakim', 'Saree', 'GPU'],
        notes: 'A product demo call about the Hakim realtime Arabic speech API.',
        hints: [{ key: 'topic', value: 'sales demo' }],
        translations: [{ from: 'حوسبة سحابية', to: 'cloud computing' }],
      },
    },
  }),
);

Every field is optional and additive. Set only what your session needs:

FieldPurpose
termsRare words, names, acronyms, or brand terms to bias recognition toward.
notesA free-text paragraph describing the call's subject matter.
hintsStructured {key, value} metadata (speaker roles, topic tags) for extra context.
translationsPinned {from, to} term pairs for cross-language glossaries. Accepted here for forward compatibility; has no visible effect until the session also translates.

vocabulary shares one combined 10,000-character budget across all four fields. There's no separate quota per field, so a long notes paragraph leaves less room for terms. Sending more than that returns invalid_frame on the session.update frame that carries it and closes the session, the same as any other malformed frame. Per field, terms takes up to 1,000 entries of 200 characters, notes up to 4,000 characters and hints up to 200 pairs.

A worked example

A short word that is easy to mishear needs a little context. In this support call the customer answers a name check with the single word "speaking", which a model can easily hear as something else. terms names the word and notes explains the situation:

ws.send(
  JSON.stringify({
    type: 'session.update',
    session: {
      model: 'hakim-arab-v2',
      language: 'en',
      trim_leading_silence: true,
      vocabulary: {
        terms: ['speaking'],
        notes:
          'A support call. The agent confirms they are speaking to the right person, ' +
          'and the customer answers with the word speaking.',
      },
    },
  }),
);

Getting the best results

  • Set it once, in the first session.update, before any audio. The vocabulary applies to the whole session and to every utterance in it, including after pauses: a session with no audio is kept open for up to 5 minutes. It is handed to the recognizer when the stream starts, so a vocabulary or language change sent in a later session.update, after audio has begun, is not applied. Open a new session to change it, and send the vocabulary again there, because a new session starts empty.
  • Set the language. Send language (or language_hints for mixed speech) instead of leaving it on auto. Context is applied far more reliably when the language is known.
  • Use terms and notes together for short, common words. terms alone is enough for names, brands and acronyms. A short everyday word that gets confused with another also needs a sentence in notes describing the situation.
  • Keep silence out of the start of the stream. Biasing becomes unreliable when the stream opens with half a second or more of silence or quiet background. Start sending when speech starts, or let us drop it for you with trim_leading_silence.
  • Keep it short and specific. A handful of the words that actually matter beats a long glossary.
  • Check the field name. Only vocabulary is read. Fields with other names, such as context or custom_vocabulary, are ignored without an error, so a misnamed field looks exactly like vocabulary that does not work.
  • Vocabulary applies to realtime sessions. The batch POST /v1/audio/transcriptions endpoint does not accept it.

Dropping silence at the start of a stream

Streams that start the moment a call connects often begin with seconds of silence or line noise before anyone speaks. That lead is billed like any other audio and, as above, it can make vocabulary less reliable. Set trim_leading_silence: true in session.update and we hold audio back until speech is detected, then send it from about 300 ms before the first word. Everything after that is untouched.

  • It is off by default, and a stream that starts with speech is passed through exactly as you sent it.
  • A lead shorter than one second is also left alone. It only acts when there is a real stretch of silence to remove.
  • It works with pcm16 and mulaw input, not opus.
  • Billing is unchanged. You are billed for all audio you send.
  • Up to about 30 seconds of leading silence is removed. A noisy line that is never quiet is passed through untouched.

Audio format

  • Codec: PCM 16-bit signed little-endian.
  • Sample rate: 16 kHz mono. Higher rates are accepted and resampled server-side, but cost more upload bandwidth without improving accuracy on the model's training distribution.
  • Frame cadence: 20–60 ms per frame. Smaller frames mean lower partial-transcript latency; larger frames mean fewer round-trips.

Latency profile

First transcription.delta typically arrives within ~150 ms of the matching audio frame for short Arabic utterances on the hakim-arab-v2 acoustic profile. Deltas keep streaming for ~1–4 s after the last audio frame (the model finishes processing the tail of the buffer), then go quiet. transcription.done follows roughly 1.5 s after the last delta; it carries the stitched final transcript for the most recent commit window. For a 5-second clip, end-to-end commit → done is ~3–5 s.

Synchronous fallback

For pre-recorded files (interviews, voicemail, podcast batches), use the synchronous POST /v1/audio/transcriptions route instead. It takes a multipart upload, transcribes the whole clip, and returns the final transcript in a single response. The realtime route is the right tool when audio is being captured now and partial transcripts need to render before the speaker pauses.

Realtime translation (WebSocket)

Open a WebSocket against wss://api.tryhakim.ai/v1/audio/translate/stream, push microphone frames in, receive transcripts + translated text + translated audio per utterance, all over one socket. Internally the endpoint chains the three realtime models you already know: hakim-arab-v2 for STT, the Hakim LLM for translation, and hakim-fast-v1 for TTS, behind the same authentication and billing surface. There is no separate scope; the connection requires stt:write and tts:write on the API key.

wscat \
  -H "Authorization: Bearer $HAKIM_API_KEY" \
  --connect wss://api.tryhakim.ai/v1/audio/translate/stream

Message protocol

Client → server:

typePurpose
session.updateSet target_language (required), source_language, voice, gender, partials.
input_audio_buffer.appendAppend a base64-encoded PCM 16-bit mono 16 kHz frame from the source mic.
input_audio_buffer.commitMark end-of-utterance · the server kicks off translation + TTS for that window.
session.closeTear down the session cleanly. Server replies and closes.

Server → client:

typePurpose
session.createdSent once on connect with the assigned session_id + resolved voice.
transcription.deltaPartial source-language transcript.
transcription.doneFinal source-language transcript for the most recent commit.
translation.deltaPartial translated text as the LLM streams tokens.
translation.doneFinal translated text for the utterance.
speech.startedTTS started for this utterance · carries voice_id + sample_rate.
speech.audioBinary frame · PCM 16-bit chunk of translated audio.
speech.doneTTS finished for this utterance · carries duration_ms.
session.usageRollup totals (STT ms, LLM tokens, TTS chars, credits, USD).
errorRecoverable or fatal error · event.fatal distinguishes the two.

Audio frames are binary WebSocket frames (unlike STT-realtime where everything is JSON) so the client doesn't pay the base64 overhead twice. Bind the speech bytes to the active utterance by keying on the most recent speech.started.

Node SDK

import { Hakim } from '@tryhakim/voice';

const hakim = new Hakim({ apiKey: process.env.HAKIM_API_KEY });
const stream = await hakim.audio.translate.streamWs({
  target_language: 'en',
  gender: 'female',
});

const audio: Uint8Array[] = [];
for await (const event of stream) {
  switch (event.type) {
    case 'transcription.done':
      console.log('source :', event.text);
      break;
    case 'translation.done':
      console.log('target :', event.text);
      break;
    case 'speech.audio':
      audio.push(event.chunk);
      break;
    case 'speech.done':
      // Concatenate `audio` into a WAV header + body, play it back.
      break;
    case 'session.usage':
      console.log('credits used', event.totals.credits);
      break;
  }
}

Python SDK

from hakim import AsyncHakim

async with AsyncHakim() as hk:
    async with hk.audio.translate.stream_ws({
        'target_language': 'en',
        'gender': 'female',
    }) as stream:
        async for event in stream:
            if event['type'] == 'transcription.done':
                print('source :', event['text'])
            elif event['type'] == 'translation.done':
                print('target :', event['text'])
            elif event['type'] == 'session.usage':
                print('credits', event['totals']['credits'])

Billing

Each utterance bills the underlying components separately so the existing usage dashboard and quota envelopes apply unchanged: STT audio ms, LLM tokens, and TTS characters all show up as their own UsageEvent. On clean close the server emits one session.usage frame with the per-session rollup so the client doesn't have to sum the components itself.

Latency profile

The first transcription.delta lands ~150 ms after the first input frame (same as STT-realtime). translation.delta starts 1–2 sentences behind the STT decoder, and the first speech.audio chunk arrives ~250 ms after translation.done for the utterance. End-to-end "user spoke → translated audio playing" runs ~1.5–3 s for short utterances, dominated by the LLM's sentence boundary detection.

Async results from long uploads

There is no public POST-to-enqueue jobs endpoint. Jobs are a read surface, GET /v1/jobs, GET /v1/jobs/{id}, GET /v1/jobs/{id}/result. That surfaces the lifecycle of work created via the routes that actually accept input:

Source routeWhen it returns a job pointer
POST /v1/audio/transcriptionsWhen the upload is > 25 MiB or > 10 minutes. Synchronous response is 202 with a poll_url.
POST /v1/audio/voicesVoice cloning always; the API returns the voice_id immediately and the model trains async.

The TTS route (POST /v1/audio/speech) is always synchronous — small inputs return audio inline, larger ones stream the audio back chunk-by-chunk. There is no batch TTS surface today.

Async STT: large file upload

curl -X POST https://api.tryhakim.ai/v1/audio/transcriptions \
  -H "Authorization: Bearer $HAKIM_API_KEY" \
  -F "file=@./long-interview.mp3" \
  -F "model=hakim-arab-v2" \
  -F "language=ar"

When the file crosses the inline threshold the response is a 202:

{
  "id": "job_01HNQ9S4ZXN4F7N6M5CY8J6F3P",
  "status": "queued",
  "kind": "transcription",
  "poll_url": "https://api.tryhakim.ai/v1/jobs/job_01HNQ9S4ZXN4F7N6M5CY8J6F3P",
  "created_at": "2026-04-17T10:00:00Z"
}

Voice clone

curl -X POST https://api.tryhakim.ai/v1/audio/voices \
  -H "Authorization: Bearer $HAKIM_API_KEY" \
  -F "name=Aya, narrator" \
  -F "language=ar" \
  -F "consent_confirmed=true" \
  -F "file=@./voice-sample-10s.wav"

Returns a Voice row with status: "processing". The voice id is usable in POST /v1/audio/speech calls after the status flips to ready (typically a few seconds for the 10-second sample profile).

Tracking completion

Two patterns; pick whichever fits your deployment.

Webhook (recommended). Subscribe to job.succeeded and job.failed via the webhooks guide. Your handler receives the job payload with the result inline. Zero polling, bounded latency.

Polling. Hit the poll_url returned by the source route (or GET /v1/jobs/{id} directly) at a paced interval:

async function waitFor(jobId: string): Promise<Job> {
  while (true) {
    const job = await client.jobs.retrieve(jobId);
    if (job.status === 'succeeded' || job.status === 'failed') return job;
    await new Promise((r) => setTimeout(r, 1_500));
  }
}

Once the job is succeeded, fetch the result envelope from GET /v1/jobs/{id}/result (transcript JSON, voice metadata, etc.).

Back off the poll interval for very long jobs. Poll requests count in the same rate-limit bucket as everything else, see the rate-limits guide, so pace accordingly.

Troubleshooting

  • Audio sounds chopped or glitchy. Browser MediaSource buffers need contiguous frames; make sure you're not dropping chunks on the way to appendBuffer. If you're proxying the stream through your own backend, confirm the proxy isn't re-buffering the body.
  • Stream hangs after N chunks. Confirm your proxy isn't buffering the full response. Nginx default behaviour is to buffer proxy_pass; set proxy_buffering off on the route that forwards to /v1/audio/speech. AWS ALB target groups buffer by default. Set the routing.http.response.buffering.enabled attribute to false on the listener rule.
  • Upstream disconnects mid-stream. The API sends a final zero-length chunk to signal clean end-of-stream. Anything else is a transport failure. The SDK throws ConnectionError; on raw fetch you'll see the underlying TCP/TLS error. Retry the request; the response is idempotent if you supply an Idempotency-Key header.
  • Realtime WebSocket closes immediately on connect. Check the close code: 4401 is auth, 4403 is missing stt:write scope, 4429 is rate-limited (the per-IP session-open budget). The body of the close frame carries a JSON error.code matching the errors guide.

Cookbook

Recipe · Build a voice agent (STT + LLM + TTS, all streaming)

The canonical conversational-AI loop: capture mic audio, transcribe on Hakim Arab v2 (STT), feed the transcript into your LLM, stream the response back as Saree' TTS. End-to-end latency lands around 500 ms with a fast LLM · comparable to OpenAI's Realtime for English.

import { WebSocket } from 'ws';
import { HakimClient } from '@hakim/node';

const hakim = new HakimClient({ apiKey: process.env.HAKIM_API_KEY });

// 1. STT realtime socket
const stt = new WebSocket('wss://api.tryhakim.ai/v1/audio/transcriptions/stream', {
  headers: { Authorization: `Bearer ${process.env.HAKIM_API_KEY}` },
});

stt.on('open', () =>
  stt.send(
    JSON.stringify({
      type: 'session.update',
      session: { language: 'ar', model: 'hakim-arab-v2' },
    }),
  ),
);

// One TTS socket reused for the whole conversation · saves the
// ~7 ms per-utterance handshake the chunked HTTP path pays on
// every call, which matters in a tight conversational loop.
const tts = hakim.audio.speech.streamWs({
  model: 'hakim-fast-v1', // Saree' · the latency-sensitive tier
  voice: 'cmokbc2b1001pvu39wmj61b7h',
});

(async () => {
  for await (const ev of tts.events) {
    if (ev.type === 'speech.audio') pushToAudioOutput(ev.chunk);
  }
})();

stt.on('message', async (raw) => {
  const event = JSON.parse(raw.toString());
  if (event.type !== 'transcription.done') return;

  // 2. Hand the final transcript to your LLM (streaming if your
  //    provider supports it; here we await the full reply for
  //    simplicity).
  const reply = await callLlm(event.text);

  // 3. Synthesize the reply over the persistent TTS socket. Audio
  //    chunks land on the iterator above as `speech.audio` events.
  tts.sendSpeech({ input: reply });
});

function pushFrame(pcm16: Buffer) {
  stt.send(
    JSON.stringify({
      type: 'input_audio_buffer.append',
      audio: pcm16.toString('base64'),
    }),
  );
}

Latency budget for the loop:

PhaseTypical wall-time
Caller stops speakingreference point
STT first transcription.delta~150 ms after the matching audio frame
STT transcription.done event~1.5 s after the last delta. Use the delta stream for fast UX, done for the committed snapshot
Your LLM responds (varies per model)200–400 ms
Hakim Saree' returns first audio chunk~113 ms p95
Audio plays through caller's speaker+ transport

If the LLM step is the bottleneck (~250 ms on GPT-4o Realtime, faster on smaller models), the Hakim legs dominate roughly a third of the budget. Move the LLM closer to the inference region, or swap to a faster model, before re-tuning the audio pipeline.

Recipe · Failover from streaming to static MP3 (IVR pattern)

When the streaming TTS connection drops mid-utterance (Wi-Fi blip, ALB drain, etc.), the caller hears clipped audio. IVR pipelines typically pre-render a fallback set of static MP3s and switch on stream failure rather than retry the stream.

import { HakimClient } from '@hakim/node';

const hakim = new HakimClient({ apiKey: process.env.HAKIM_API_KEY });

async function speakOrFallback(line: string, fallbackUrl: string) {
  try {
    const { stream } = await hakim.audio.speech.stream({
      model: 'hakim-fast-v1',
      input: line,
      voice: 'alex',
      response_format: 'mp3',
      stream: true,
    });
    for await (const chunk of stream) playToCaller(chunk);
  } catch (err) {
    // Stream error → fall back to pre-recorded static.
    const blob = await fetch(fallbackUrl);
    for await (const chunk of blob.body!) playToCaller(chunk);
  }
}

Pre-render the top-20 IVR prompts on every deploy (TTS is cheap; the failsafe is essentially free) and store the MP3s on the same CDN as the rest of your application. Caller hears the fallback within a few hundred ms of the stream error rather than a 30-second timeout.

Recipe · Stream long-form Faseeh narration

Faseeh streams the same way Saree' does · the wire format and SDK call shape are identical. The marketing pitch is naturalness, not speed, so don't promise a TTFB to your users on this tier; do promise that they can play the audio as it renders rather than waiting for the full file.

curl -N https://api.tryhakim.ai/v1/audio/speech \
  -H "Authorization: Bearer $HAKIM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "hakim-v2",
    "input": "[breath] Welcome back, listeners. Today we are talking about ...",
    "voice": "layla-egyptian",
    "response_format": "mp3",
    "stream": true
  }' \
  --output episode-intro.mp3

For an hour-long episode, this lets the user press play within a second of submitting the script instead of waiting for the whole render · useful for previewing in the playground or as a UX nicety in your authoring tool. Render the final master via a webhook batch job for production-grade quality control.