Rate limits

Every synthesis call moves through two independent limits:

  1. a per-endpoint quota that's specific to the operation (TTS, STT, voice-clone, webhooks, etc.), and
  2. a plan quota covering the org's overall monthly usage.

You hit (1) when you burst too fast; you hit (2) when you run out of credits for the month. Both surface through the same 429 status code and the same set of X-RateLimit-* headers, so the retry logic is uniform across the two.

Response headers

Every successful request carries these headers so your integration can rate-shape itself without speculative retries:

  • X-RateLimit-Limit, the endpoint's per-minute quota.
  • X-RateLimit-Remaining, requests you have left in the current window.
  • X-RateLimit-Reset, Unix timestamp (seconds) when the window refills.
  • X-Hakim-Monthly-Remaining, characters / seconds of audio left on the plan this billing cycle.

On throttled (429) responses we additionally set:

  • Retry-After, seconds to wait before retrying. Always honour it.

The headers are deliberately on every response (including 200s) so you can implement proactive pacing rather than only reacting to 429s.

Typical quotas

Exact limits per plan live on the pricing page; the shape is:

Endpoint classFree tierBuilderScaleEnterprise
TTS60 rpm600 rpm3 000 rpmCustom
STT20 rpm200 rpm1 000 rpmCustom
Voice clone5 rpm60 rpm300 rpmCustom
Voices list120 rpm600 rpm3 000 rpmCustom
Webhooks60 rpm600 rpm3 000 rpmCustom

The free tier also caps burstable tokens per month, see the plan comparison on the pricing page for exact caps.

Dealing with a 429

The minimal-but-correct reaction is:

try {
  await client.audio.speech.create({ input: text, voice });
} catch (err) {
  if (isRateLimited(err)) {
    const retryAfter = Number(err.headers?.get('retry-after')) * 1000;
    await new Promise((r) => setTimeout(r, retryAfter || 1000));
    return client.audio.speech.create({ input: text, voice });
  }
  throw err;
}

function isRateLimited(err: unknown): boolean {
  return Boolean(
    err &&
    typeof err === 'object' &&
    'status' in err &&
    (err as { status?: number }).status === 429,
  );
}

For queued workloads (e.g. batch TTS over a long article list):

  • Pace to the limit. Keep an in-memory token bucket sized at X-RateLimit-Limit / 60 per second. Refill it from the most recent X-RateLimit-Reset.
  • Stop sending on Remaining === 0. Back off until X-RateLimit-Reset rather than relying on the next call to 429.
  • Use idempotency keys for retries. Every mutating endpoint accepts an Idempotency-Key header. Reusing the same key after a 429 guarantees the server won't double-bill you if the original request actually succeeded before the client gave up.

Hitting the monthly cap

When the org's X-Hakim-Monthly-Remaining reaches zero, every subsequent call returns 429 with code: "hard_cap_exceeded" (if a hard cap is configured) or code: "rate_limited" with a Retry-After pointing at the next billing cycle rollover.

Two levers:

  1. Raise (or remove) the hard cap in Billing → Plan on the dashboard. This is the single place that changes the cap, the API rejects requests to set one without an explicit confirmation in the dashboard UI (see the confirmedAbsoluteCutoff flag in the internal API).
  2. Upgrade the plan. The upgrade takes effect immediately, refills the quota, and prorates the switch on the next invoice.

Request shaping at scale

For high-throughput integrations (podcast production pipelines, conversational agents, voice notifications at fan-out):

  • Reuse connections. HTTP/2 multiplexing means a single TCP socket can carry dozens of concurrent requests with minimal handshake cost. The Node + Python SDKs enable this by default.
  • Prefer streaming for TTS. The streaming endpoint lets you start playback before the entire response has finished, see the streaming guide.
  • Batch where possible. Long-form narration is almost always better served by submitting a single /v1/audio/jobs job than by fanning out hundreds of short synchronous calls.

TTS family · tier-specific budgeting

The TTS quota (60–3 000 rpm depending on plan) is shared across all three tiers · Saree' / Faseeh / Mulhim. The quota meters total requests-per-minute on POST /v1/audio/speech, not per model. That keeps the surface simple: you can mix tiers freely inside your minute without splitting your rate-limit headroom.

Two implications worth planning for:

  1. Premium-tier renders cost more in compute (Faseeh + Mulhim run on the premium inference pool) so per-character pricing differs; see /pricing for the exact spread. The rate-limit doesn't move · pricing does.
  2. Voice design has a separate budget. POST /v1/audio/voices/design sits under the Voice clone row in the table above, not the TTS row, because each design call is a small training pass. Don't pre-render a thousand candidate voices on free tier (you'll trip the 5 rpm limit); use the iteration recipe to design 3–5 candidates and save the winner.

Cookbook

Recipe · Pace a podcast batch (fan-out pattern)

A podcast pipeline that renders 40 episodes overnight is the canonical shape for hitting the per-minute quota. The pattern below uses a token bucket sized off the response headers; it's plan-agnostic.

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

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

// In-memory token bucket · refilled from response headers
let tokens = 60; // start optimistic; first call corrects this
let resetAt = Date.now() / 1000 + 60;

async function paced<T>(call: () => Promise<T>): Promise<T> {
  if (tokens <= 0) {
    const waitMs = Math.max(0, resetAt * 1000 - Date.now()) + 50;
    await new Promise((r) => setTimeout(r, waitMs));
    tokens = 60; // optimistic; the next response refines
  }
  tokens -= 1;
  const res = await call();
  // SDK exposes the rate-limit headers on every successful response
  const headers = (res as { _response?: { headers: Headers } })._response?.headers;
  if (headers) {
    tokens = Number(headers.get('x-ratelimit-remaining') ?? tokens);
    resetAt = Number(headers.get('x-ratelimit-reset') ?? resetAt);
  }
  return res;
}

const episodes = await loadEpisodeScripts(); // 40 strings
const results = await Promise.all(
  episodes.map((script) =>
    paced(() =>
      hakim.audio.speech.create({
        model: 'hakim-v2', // Faseeh for narration
        input: script,
        voice: 'aya-narrator',
        response_format: 'mp3',
      }),
    ),
  ),
);

The bucket self-corrects against the live X-RateLimit-Remaining / X-RateLimit-Reset headers rather than the static plan limit, so it adapts to plan upgrades the same day they take effect.

Recipe · Survive a hard_cap_exceeded mid-render

When the org's monthly hard cap is hit mid-batch, in-flight requests complete but new ones return 429 hard_cap_exceeded with a Retry-After pointing at the next billing cycle. For a production batch this is rarely the right answer; the cap exists to prevent runaway spend, not to abandon a publication-day render.

async function speakOrEscalate(text: string, voice: string) {
  try {
    return await hakim.audio.speech.create({
      model: 'hakim-v2',
      input: text,
      voice,
      response_format: 'mp3',
    });
  } catch (err) {
    if (isCode(err, 'hard_cap_exceeded')) {
      // Page the on-call so they can raise the cap from the
      // dashboard. Don't retry until they confirm.
      await pageOnCall({
        runbook: 'https://yourcompany.example/runbooks/hakim-cap',
        cap_request_id: (err as { headers?: Headers }).headers?.get('x-request-id'),
      });
      throw err;
    }
    throw err;
  }
}

function isCode(err: unknown, code: string): boolean {
  return Boolean(
    err && typeof err === 'object' && 'code' in err && (err as { code: string }).code === code,
  );
}

The cap is raised from Billing → Plan on the dashboard (confirmedAbsoluteCutoff flag); the API can't lift it without an explicit human confirmation, so make sure your runbook has the path documented for the on-call.