Rate limits
Every synthesis call moves through two independent limits:
- a per-endpoint quota that's specific to the operation (TTS, STT, voice-clone, webhooks, etc.), and
- 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 class | Free tier | Builder | Scale | Enterprise |
|---|---|---|---|---|
| TTS | 60 rpm | 600 rpm | 3 000 rpm | Custom |
| STT | 20 rpm | 200 rpm | 1 000 rpm | Custom |
| Voice clone | 5 rpm | 60 rpm | 300 rpm | Custom |
| Voices list | 120 rpm | 600 rpm | 3 000 rpm | Custom |
| Webhooks | 60 rpm | 600 rpm | 3 000 rpm | Custom |
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 / 60per second. Refill it from the most recentX-RateLimit-Reset. - Stop sending on
Remaining === 0. Back off untilX-RateLimit-Resetrather than relying on the next call to 429. - Use idempotency keys for retries. Every mutating endpoint
accepts an
Idempotency-Keyheader. Reusing the same key after a429guarantees 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:
- 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
confirmedAbsoluteCutoffflag in the internal API). - 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/jobsjob 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:
- Premium-tier renders cost more in compute (Faseeh + Mulhim run
on the premium inference pool) so per-character pricing differs;
see
/pricingfor the exact spread. The rate-limit doesn't move · pricing does. - Voice design has a separate budget.
POST /v1/audio/voices/designsits 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.