Errors

Every non-2xx response from the Hakim API uses the same envelope. Your integration can branch on a single machine-readable code regardless of which endpoint raised the error.

The envelope

{
  "error": {
    "type": "validation_error",
    "code": "text_too_long",
    "message": "The text field must be 5,000 characters or fewer.",
    "request_id": "req_01HNQ9S4ZXN4F7N6M5CY8J6F3P",
    "details": {
      "field": "text",
      "limit": 5000,
      "received": 5432
    }
  }
}
  • type, coarse category (validation_error, authentication_error, permission_error, rate_limited, api_error, upstream_error). Useful for generic retry logic.
  • code, stable, machine-readable identifier. Branch on this, never parse message.
  • message, human-readable explanation, safe to surface in logs.
  • request_id, include this when you open a support ticket; it correlates to our server logs within 24 hours.
  • details, endpoint-specific context (not every error carries it).

HTTP status contract

StatusTypical typeRetryable
400validation_errorNo
401authentication_errorNo
403permission_errorNo
404validation_error (resource not found)No
409validation_error (idempotency conflict)No
422validation_error (semantic)No
429rate_limitedYes, backoff
5xxapi_error / upstream_errorYes, backoff

Anything in the 4xx range is caused by the caller; retrying without changing the request will fail identically. Anything in the 5xx range should be retried with exponential backoff.

Canonical codes

A subset of the codes your integration is most likely to encounter. The live list is generated from the OpenAPI document at /docs/api, treat that as the source of truth.

Authentication

  • missing_api_key, no Authorization header or the header did not begin with Bearer.
  • invalid_api_key, the presented key does not match any active key.
  • revoked_api_key, the key existed but was revoked. The dashboard records the revocation timestamp under Settings → API keys.

Validation

  • text_too_long, TTS text exceeds the plan's character cap (configurable per plan; see platform usage).
  • unsupported_language, the language you specified is not in the model's supported set. Call /v1/voices?language=<tag> to inspect what is available.
  • audio_too_long, STT input exceeds the per-request duration cap.
  • invalid_mime_type, the audio you uploaded does not decode into a supported codec. See the reference for the accepted list.

Permission

  • insufficient_org_role, your key belongs to a user without the required role (owner / admin / billing / member).
  • feature_not_in_plan, the feature is gated behind a higher plan. The response includes a required_plan in details.

Throttling

  • rate_limited, you exceeded the per-endpoint quota. See rate limits for header semantics and the retry strategy.
  • hard_cap_exceeded, the org's monthly hard cap is hit. No further paid usage will succeed until the cap is raised or the period rolls over. The dashboard surfaces this on the billing page.

Upstream / transient

  • model_cold, the first request after a long idle period can hit a cold model replica; retry within 500 ms.
  • upstream_timeout, our inference layer took too long to respond. Retry once, then escalate.
  • internal_error, unexpected server fault. Include request_id in any support ticket.

Retry strategy

A minimal-but-correct retry loop looks like this (Node.js):

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

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

async function speakWithRetry(text: string, attempts = 3) {
  for (let attempt = 1; attempt <= attempts; attempt++) {
    try {
      return await client.audio.speech.create({ input: text, voice: 'cmokbc2b1001pvu39wmj61b7h' });
    } catch (err) {
      if (!isRetryable(err) || attempt === attempts) throw err;
      const delay = retryAfterMs(err) ?? 250 * 2 ** (attempt - 1);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}

function isRetryable(err: unknown): boolean {
  if (!(err && typeof err === 'object' && 'status' in err)) return false;
  const { status } = err as { status: number };
  return status === 429 || (status >= 500 && status < 600);
}

function retryAfterMs(err: unknown): number | null {
  const headers = (err as { headers?: Headers }).headers;
  const retryAfter = headers?.get('retry-after');
  return retryAfter ? Number(retryAfter) * 1000 : null;
}

Key points:

  1. Respect Retry-After when the server sets it; our 429 responses always do.
  2. Cap the backoff at 30 seconds so a long outage doesn't block your caller thread indefinitely.
  3. Never retry 4xx outside of 429. They are deterministic failures.

Seeing errors in the dashboard

Every failed API call is indexed in the Usage → Events tab of the dashboard with the full code, status, and (for your own debugging) the stringified request body. The request_id in the response envelope matches the id column there, so you can jump from a production log line directly to the stored event.

Cookbook

Recipe · Detect capability warnings on the TTS path

Not every problem is a non-2xx response. When a request lands on a tier that doesn't support every field, the server gracefully strips the unsupported bits and tells you via the x-hakim-warnings header. The audio still plays · your client decides whether to surface the warning to the user, retry on a higher tier, or silently move on.

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

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

const { stream, contentType, warnings } = await hakim.audio.speech.stream({
  model: 'hakim-fast-v1',
  input: 'Hello [laugh] world',
  voice: 'alex',
  voice_prompt: 'elderly raspy woman',
  response_format: 'mp3',
});

if (warnings?.includes('tags_stripped_by_model_capability')) {
  // The user wrote tags on Saree' (which strips them). Either surface
  // a soft warning in your UI ("Tags work on Faseeh and Mulhim only")
  // or retry on `hakim-v2` if your product surface expects tag-rich
  // playback.
}

if (warnings?.includes('voice_prompt_dropped_by_model_capability')) {
  // `voice_prompt` is Mulhim-only. Same treatment.
}

The set of warning codes is intentionally small so you can switch on it:

CodeMeaningTier promoted to fix
tags_stripped_by_model_capability[laugh] etc. were in the input but the tier stripshakim-v2 or hakim-v3
voice_prompt_dropped_by_model_capabilityvoice_prompt field was sent but the tier ignoreshakim-v3

Always treat the warning as advisory. The render succeeded; the warning is telling you what the user wrote got reshaped to match the tier they hit.

Recipe · Retry with idempotency on flaky networks

Every mutating endpoint accepts an Idempotency-Key header. Reusing the same key after a transient failure (network blip, 5xx, 429) guarantees the server won't double-bill you if the original request actually succeeded before the client gave up. The key is yours to pick · UUIDv4 is fine.

import { randomUUID } from 'node:crypto';
import { HakimClient } from '@hakim/node';

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

async function speakIdempotent(text: string, voice: string) {
  const idemKey = randomUUID();
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      return await hakim.audio.speech.create(
        { model: 'hakim-fast-v1', input: text, voice, response_format: 'mp3' },
        { headers: { 'Idempotency-Key': idemKey } },
      );
    } catch (err) {
      if (!isRetryable(err) || attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, 250 * 2 ** (attempt - 1)));
    }
  }
  throw new Error('exhausted retries');
}

The idempotency window is 24 hours. Hold onto the key until the business operation (e.g. invoice settled, podcast episode published) is complete, then drop it. The window is per-org, so multiple processes in the same org can safely share the key.

Recipe · Promote to a higher tier on capability mismatch

For products that genuinely depend on tags or voice prompts, the x-hakim-warnings signal is your trigger to retry on the right tier rather than silently delivering a degraded render:

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

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

async function speakWithCapabilityPromote(input: string, voice: string) {
  // First attempt on the cheap tier.
  const first = await hakim.audio.speech.stream({
    model: 'hakim-fast-v1',
    input,
    voice,
    response_format: 'pcm',
    stream: true,
  });

  if (first.warnings?.includes('tags_stripped_by_model_capability')) {
    // The user's script depends on tags landing. Re-render on Faseeh.
    return hakim.audio.speech.stream({
      model: 'hakim-v2',
      input,
      voice,
      response_format: 'pcm',
      stream: true,
    });
  }

  if (first.warnings?.includes('voice_prompt_dropped_by_model_capability')) {
    // voice_prompt is Mulhim-only.
    return hakim.audio.speech.stream({
      model: 'hakim-v3',
      input,
      voice,
      response_format: 'pcm',
      stream: true,
    });
  }

  return first; // No promotion needed.
}

Watch the cost: a double-render on every request adds up. For high-volume use cases, prefer picking the right tier at the call site (use the tier-picker recipe) and reserve the auto-promote path for "I don't know what the user typed" surfaces like a freeform playground.