Natural Burmese (Myanmar) speech from your own code: send text, get a job back at once, and download the audio when it is ready.
Base URLhttps://api.athanlab.com/api/v1
Invite-only
The Developer API is live for a small group of invited partners. Access needs an invitation, an approved application and the Max plan. Invited? Sign in with the email address we invited and apply on the Developer API page of your dashboard.
The AthanLab API turns Burmese text into speech with the same engine, voices and character balance as the AthanLab app. It works with asynchronous jobs, so no request waits for audio.
How it works
Create.POST /speech returns 202 Accepted with the job.
Poll.GET /speech/{id} until its status is no longer processing, as described in Polling.
Download.GET /speech/{id}/audio returns the MP3 or WAV file.
To listen on athanlab.com instead of downloading a file, send the text to AthanInbox with POST /deliveries.
At a glance
The Developer API at a glance
Item
Value
Base URL
https://api.athanlab.com/api/v1
Authentication
An API key in X-API-Key or Authorization: Bearer. See Authentication.
Access is currently by invitation. Do every step signed in with the email address we invited: the application form appears only for that account. Not invited? Email support@athanlab.com to ask about access.
Apply
On the Developer API page of your dashboard, give your company or project, what you are building and your expected monthly volume. Then accept the API Terms.
Wait for review
We review every application by hand and email you the decision. A rejection email says why and when you can apply again.
Subscribe to Max
To pay with KBZPay instead of a card, see Billing.
Create a key
Keys need an approved developer application and an active Max plan. You can hold up to 5 keys at a time, each valid for 30, 90, 180 or 365 days (default 365). A key is shown once, when you create it: see Authentication.
When your Max plan ends
Creating a job or a delivery returns 402 plan_required, and so does a dry run. Every other request keeps working, including polling, downloads, cancelling and GET /usage, and audio already in AthanInbox stays there. Your keys create jobs and deliveries again when you resubscribe.
A key is ak_live_ followed by 32 hex characters. It is shown once, when you create it, so store it in your secret manager.
A missing key returns 401 missing_api_key. An unknown, malformed, revoked or expired key returns 401 invalid_api_key. Unknown and malformed keys also count toward the per-network limit on failed key checks, which blocks a network for 10 minutes with 429 auth_blocked (see Rate limits).
Keep keys on your server
Never put an API key in a browser, a mobile app or a public repository: anyone who can read it can spend your balance. If a key leaks, revoke it in the dashboard at once, or rotate it and choose Stop the old secret immediately.
Scopes
A key can call only the endpoints its scopes allow:
Rotating gives a key a new secret. The 24-hour grace, which keeps the old secret working while you deploy the new one, is for planned rotations only. To end a running grace early, choose Stop old secret.
Three requests: create a job, poll it, download the audio.
Before you start
You need an API key with the speech:write and speech:read scopes (see Get access). Set it as ATHANLAB_API_KEY in the environment of a server-side process.
The response also carries Location, the path of the job, and Retry-After: 2. In production, send an Idempotency-Key with every create so a retry never charges twice (see Safe retries).
Poll the job
Put the id from the response in JOB_ID, then fetch the job.
With --fail, curl saves nothing when the request fails, so an error body never ends up in speech.mp3.
Complete script
The same three steps as one program, with an Idempotency-Key and back-off between polls:
curl
terminal
# Set ATHANLAB_API_KEY to your key from the Developer API page first. Keep it server-side.
# 1. Create a job. The response is 202 Accepted with the job.
# Keep its id for the next steps. If the API rejects the request, the error is printed instead.
JOB_ID=$(curl -sS https://api.athanlab.com/api/v1/speech \
-H "X-API-Key: $ATHANLAB_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(python3 -c 'import uuid; print(uuid.uuid4())')" \
-d '{"text": "မင်္ဂလာပါ။ အသံလက်ဘ်မှ ကြိုဆိုပါတယ်။", "output_format": "mp3"}' \
| python3 -c 'import json, sys; job = json.load(sys.stdin); print(job.get("id") or sys.exit(json.dumps(job)))')
echo "$JOB_ID"
# 2. Poll until the job is no longer processing.
# Wait 2 seconds first, the Retry-After of the create response, then about 10 seconds between polls.
sleep 2
while :; do
JOB=$(curl -sS https://api.athanlab.com/api/v1/speech/$JOB_ID -H "X-API-Key: $ATHANLAB_API_KEY")
STATUS=$(printf '%s' "$JOB" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("status", "error"))')
echo "$STATUS"
[ "$STATUS" = "processing" ] || break
sleep 10
done
echo "$JOB"
# 3. Download the audio when the job succeeded.
# With --fail, curl writes no file on an HTTP error, since an error body is not audio.
[ "$STATUS" = "succeeded" ] && curl -sS --fail -o speech.mp3 https://api.athanlab.com/api/v1/speech/$JOB_ID/audio \
-H "X-API-Key: $ATHANLAB_API_KEY"
Python
quickstart.py
import os, time, uuid
import requests # pip install requests
BASE = "https://api.athanlab.com/api/v1"
HEADERS = {"X-API-Key": os.environ["ATHANLAB_API_KEY"]}
# 1. Create the job. The Idempotency-Key makes retrying this POST safe:
# a repeat returns the same job instead of charging twice.
res = requests.post(
f"{BASE}/speech",
headers={**HEADERS, "Idempotency-Key": str(uuid.uuid4())},
json={"text": "မင်္ဂလာပါ။ အသံလက်ဘ်မှ ကြိုဆိုပါတယ်။", "output_format": "mp3"},
timeout=30,
)
res.raise_for_status()
job = res.json()
# 2. Poll until the job finishes: first after the create response's Retry-After,
# then backing off to 10 s (a successful poll sends no Retry-After).
delay = float(res.headers.get("Retry-After", 2))
while job["status"] == "processing":
time.sleep(delay)
res = requests.get(f"{BASE}/speech/{job['id']}", headers=HEADERS, timeout=30)
if res.status_code == 429:
delay = float(res.headers.get("Retry-After", delay))
continue
res.raise_for_status()
job = res.json()
delay = min(delay * 1.5, 10)
if job["status"] != "succeeded":
raise RuntimeError(f"job {job['status']}: {job['error']}")
# 3. Download the audio.
audio = requests.get(f"{BASE}/speech/{job['id']}/audio", headers=HEADERS, timeout=120)
audio.raise_for_status()
with open("speech.mp3", "wb") as f:
f.write(audio.content)
Node.js
quickstart.mjs
// Node.js 18+ — save as quickstart.mjs and run: node quickstart.mjs
import { randomUUID } from 'node:crypto'
import { writeFile } from 'node:fs/promises'
const BASE = 'https://api.athanlab.com/api/v1'
// An unset key would go out as the header "undefined": rejected, and counted
// toward the per-network limit on failed key checks.
const apiKey = process.env.ATHANLAB_API_KEY
if (!apiKey) throw new Error('Set ATHANLAB_API_KEY to your API key first.')
const headers = { 'X-API-Key': apiKey }
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000))
async function check(res) {
if (!res.ok) throw new Error(JSON.stringify(await res.json()))
return res
}
// 1. Create the job. The Idempotency-Key makes retrying this POST safe.
let res = await check(await fetch(`${BASE}/speech`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': randomUUID() },
body: JSON.stringify({ text: 'မင်္ဂလာပါ။ အသံလက်ဘ်မှ ကြိုဆိုပါတယ်။', output_format: 'mp3' }),
}))
let job = await res.json()
// 2. Poll until the job finishes: first after the create response's Retry-After,
// then backing off to 10 s (a successful poll sends no Retry-After).
let delay = Number(res.headers.get('Retry-After') ?? 2)
while (job.status === 'processing') {
await sleep(delay)
res = await fetch(`${BASE}/speech/${job.id}`, { headers })
if (res.status === 429) {
delay = Number(res.headers.get('Retry-After') ?? delay)
continue
}
job = await (await check(res)).json()
delay = Math.min(delay * 1.5, 10)
}
if (job.status !== 'succeeded') throw new Error(`job ${job.status}: ${job.error?.code}`)
// 3. Download the audio.
res = await check(await fetch(`${BASE}/speech/${job.id}/audio`, { headers }))
await writeFile('speech.mp3', Buffer.from(await res.arrayBuffer()))
Sending your own text? Read Writing text for speech first: sentence punctuation, made-up names, and how numbers are read.
The API reads exactly the text you send. These rules make it sound natural and keep a request from being rejected.
Sentences and punctuation
End every sentence with ။, ! or ?, and keep sentences short. Text is spoken sentence by sentence, in pieces. A dry run's dispatches field counts the pieces your text becomes (see Quote first with dry_run).
A sentence too long for one piece is cut at a ၊, then at a space, and mid-word only as a last resort. So a long stretch without punctuation breaks in odd places. If a piece still cannot be made short enough, the request is rejected before any charge with 422 text_too_long: “Part of the text is too long to speak in one piece. Add sentence punctuation and retry.”
Burmese and English
Burmese, English, and Burmese with English words mixed in all work. Made-up names and brand names (for example AthanLab) can come out wrong. Spell them the way they sound in Burmese (အသံလက်ဘ်) and listen to a sample first.
Marks the voice skips
Quotation marks, brackets and … are not spoken well. Remove them, or put a ၊ where you want a short pause. Text that is only emoji, punctuation or whitespace has nothing to read. It is rejected before any charge with 422 unspeakable_text.
Numbers
number_mode sets how digits are read. Here is each mode reading ကျပ် ၁၂၃၄:
What each number_mode reads
Mode
What it reads
ကျပ် ၁၂၃၄ is spoken as
smart
The default. Burmese digits as Burmese words — digit by digit after a leading zero and for runs of 9 or more digits, with ဒဿမ for a decimal point. Latin digits (1234) are left as written.
ကျပ် တစ်ထောင့်နှစ်ရာ့သုံးဆယ့်လေး
place
Every number, in Burmese or Latin digits, as Burmese place words (up to 8 digits; longer runs digit by digit).
ကျပ် တစ်ထောင့်နှစ်ရာ့သုံးဆယ့်လေး
digits
Burmese, one digit at a time: phone numbers, codes, IDs.
ကျပ် တစ် နှစ် သုံး လေး
english
English number words.
ကျပ် one thousand two hundred thirty four
english_digits
English, one digit at a time.
ကျပ် one two three four
Numbers written out as words cost nothing extra, but the expanded text must stay within 10,000 characters, or the request returns 422 text_too_long.
Scripts and dialogue
Split long scripts into sentence-sized lines and create one job per line, each with its speaker's voice_id. Every line then has its own audio file to place on a timeline, retime or replace. Fixing one line regenerates, and charges, only that line.
Your account runs a limited number of jobs at once (see Rate limits and quotas), so queue the lines and submit the next as earlier ones finish. For AI agents, athanlab_speak_script creates one job per line, each in its speaker's voice, and saves the files on one timeline (see AI agents (MCP)).
Poll GET /speech/{id} while the job is processing: wait the Retry-After of the create response (2 seconds) before the first poll, then back off to about 10 seconds between polls. A successful poll carries no Retry-After of its own, so keep your own delay.
Polls count against the per-key read limit of 600 requests a minute, and against the per-network limit. A rate-limited poll returns 429 with its own Retry-After.
To follow many jobs, list the ones still processing in one request instead of polling each:
The same call with status=failed finds the jobs that failed.
Webhooks
The API does not send webhooks. Poll for the outcome as described above.
Safe retries
Send an Idempotency-Key header with a new UUID on every POST /speech and POST /deliveries, and reuse it when you retry that request. A network can fail after the server has accepted a job, and the key keeps the retry from creating and charging a second one.
Within 24 hours, the same key and body return the original job (or delivery) with Idempotent-Replayed: true and no new charge. The same key with a different body returns 409 idempotency_conflict. An Idempotency-Key used on POST /speech never conflicts with the same value on POST /deliveries.
Quote first with dry_run
Add "dry_run": true to check a request and see its price. Nothing is created or charged, and the response is a DryRunQuote, as in this example:
characters is what the job would cost, dispatches the number of pieces the text is spoken in, and sufficient whether your balance covers it.
Cancel
POST /speech/{id}/cancel stops a job that is still processing. The share already generated stays charged and the rest is refunded, as usage.characters_refunded shows. Cancel works while the API is in read-only mode and after your Max plan ends. A finished job returns 409 job_finished.
Request
curl -sS -X POST https://api.athanlab.com/api/v1/speech/$JOB_ID/cancel \
-H "X-API-Key: $ATHANLAB_API_KEY"
Retention
Audio and input text are kept for 30 days after the job is created (audio.expires_at), then deleted. Download what you need to keep. After that, a download returns 410 audio_expired, but the job record (status, characters, metadata) stays readable.
Audio and text sent to AthanInbox are the exception: they are kept until you delete them in AthanInbox or delete your account (see AthanInbox).
The API never returns your input text.
Job failure codes
A failed job reports one of these codes in error.code. They are not HTTP errors: the poll itself succeeds with status: "failed", and the characters are refunded in full.
capacity_unavailable has two meanings
As a job failure, the job was charged and then refunded, so retry later. As an HTTP 503 when you create a job, nothing was charged: wait the Retry-After, then retry (see 503 capacity_unavailable). That 503 can reach you as an HTML 504 (see Errors).
Job failure codes
Code
Retryable
Meaning
input_rejected
No
The text could not be turned into speech. Change the input before retrying. The characters were refunded.
capacity_unavailable
Yes
No speech capacity became available before the job deadline. Retry later. The characters were refunded.
generation_failed
Yes
Speech generation failed. Retry the request. The characters were refunded.
interrupted
Yes
The job was interrupted by a server restart. Retry the request. The characters were refunded.
GET /voices lists AthanLab's official voices (source: "athanlab") and the voices in your own AthanLab library (source: "user"). Pass a voice's id as voice_id. The official voices include athanlab-default-female-v1 and athanlab-default-male-v1.
If you leave voice_id out, the job uses default_voice_id from the same response. If that is null, the default is briefly unavailable, so send a voice_id.
GET /voices/{id}/preview returns a signed sample URL, valid for one hour, so your users can hear a voice before choosing it.
You add library voices in the AthanLab app, under its consent rules: only your own voice, or one you have the speaker's explicit permission to use (see the Acceptable Use Policy).
The API spends the same character balance as the AthanLab app: your Max plan's monthly allowance first, then any Athan Tokens (characters you buy in the AthanLab app). One character through the API is one character in the app. GET /usage shows both.
What is charged
What each kind of request costs
Event
Characters
Accepted job
The length of its text in UTF-16 code units (see Size and retention), charged when the job is accepted
Failed job
Refunded in full, automatically
Cancelled job
The generated share stays charged. The rest is refunded.
Delivery to AthanInbox
The length of all its chapters, charged when it is accepted. A chapter that fails is refunded automatically (see AthanInbox).
Delivery deleted while it is being made
Chapters already made stay charged, and the chapter in progress only for its generated share. The rest is refunded.
You can give any key an optional monthly character budget on the Developer API page. It is counted per UTC calendar month. Past it, creating a job or a delivery with that key returns 429 key_budget_exceeded and nothing is charged. For agents, see A key for each agent.
Paying for Max
Pay with KBZPay rather than a card? Email support@athanlab.com and we set up a Max subscription for you. For what your keys can still do after your plan ends, see When your Max plan ends under Get access.
@athanlab/mcp is an MCP server that lets AI agents in Claude Code, Codex (the CLI and the desktop app), Claude Desktop, Cursor and other MCP clients that run local (stdio) servers create, poll and download speech through this API with your key. Ask for audio in plain words: the agent creates the job, waits for it and saves the file in a local folder. A script becomes one audio file per line plus a manifest.json timeline, ready for subtitles and video. Or the agent sends the audio to AthanInbox, to listen to on athanlab.com.
The server runs on your machine, calls the same endpoints as the Quickstart and spends the same balance.
Requirements
Node.js 20 or later on the machine that runs the agent.
claude mcp add athanlab -e ATHANLAB_API_KEY=ak_live_your_key_here -- npx -y @athanlab/mcp
Options go before --, the server command after it. The server is added to the current project. Add --scope user to use it in every project. Either way audio goes to an athanlab-audio folder in the project you are working in.
-e stores the key in plain text in ~/.claude.json, and in your shell history unless you start the command with a space (zsh HIST_IGNORE_SPACE, bash HISTCONTROL=ignorespace).
To share a setup with a team, commit a .mcp.json that reads ${ATHANLAB_API_KEY} from each person's environment, never one with a key in it. Check with claude mcp list.
For the Codex desktop app, use the Codex desktop tab: the app reads this same file, but env_vars finds no key there.
With this entry, env_vars is required. Codex starts MCP servers with a filtered environment, so without it the key never arrives. It forwards ATHANLAB_API_KEY only if the variable is set in the shell that launches Codex. Export the key in that shell, or in its profile, before you start Codex.
Codex stops waiting for a tool after 60 seconds unless told otherwise, but athanlab_speak and athanlab_speak_script wait up to 15 minutes by default, hence tool_timeout_sec = 960. Keep any timeout_seconds you give them below it. If a call is still cut off, finish its job rather than start it again (see Tools).
startup_timeout_sec = 30 leaves room for the first npx download.
Codex starts the server in the session folder, so audio goes to an athanlab-audio folder there.
codex mcp add athanlab --env ATHANLAB_API_KEY=ak_live_your_key_here -- npx -y @athanlab/mcp also works, but it stores the key in plain text in config.toml and still needs tool_timeout_sec added by hand, and startup_timeout_sec too, since Codex otherwise waits only 10 seconds for the server to start.
In the app, open Settings → MCP servers → Add server → STDIO, give the command, Save, then Restart. Editing ~/.codex/config.toml works too: the app shares it with Codex CLI. Either way the entry needs the env table and both timeouts above.
The key goes in env. The app does not load your shell profile, so the servers it starts get none of the variables ~/.zshrc exports, and env_vars = ["ATHANLAB_API_KEY"] finds nothing.
PATH must include the folder that holds node. The app's own PATH does not, and npx runs on node, so without it the server never starts (env: node: No such file or directory). Find the folder with dirname "$(command -v node)" and put the absolute path it prints first in PATH: /opt/homebrew/bin for Homebrew on Apple Silicon, /usr/local/bin for Homebrew on Intel (both already in the PATH above), /Users/you/.nvm/versions/node/<version>/bin with nvm.
Give the output folder as an absolute path. The app starts the server in the thread's project folder, but one server can serve several threads.
The timeouts are the CLI's: tool_timeout_sec = 960 outlasts the speaking tools' 15-minute wait, and startup_timeout_sec = 30 leaves room for the first npx download.
The key sits in this file in plain text. Keep the file private (chmod 600 ~/.codex/config.toml) and never commit or share it.
Codex CLI reads the same file and the same [mcp_servers.athanlab] entry, so keep only this one: it works for the CLI too. The Codex tab's env_vars entry keeps the key out of the file, but finds no key in the app.
${env:ATHANLAB_API_KEY} reads the key from your environment, so it stays out of the file.
For one project, put the same entry in that project's .cursor/mcp.json with ${workspaceFolder}/athanlab-audio as the output folder.
Agent skill
The package ships the athanlab-voice skill. It teaches an agent when to use these tools, how to write text for speech, cast voices, quote before spending and turn a manifest into subtitles. The skill needs the MCP server and adds no tools of its own.
The Codex desktop app uses the same skill folder as Codex CLI. For one project, put it in .claude/skills/athanlab-voice/ (Claude Code) or .agents/skills/athanlab-voice/ (Codex) inside that project instead. Start a new session to load it.
Tools
The server registers these tools. Those marked “no charge” spend no characters, but their API requests still count toward the rate limits.
Tools of the MCP server
Tool
What it does
Scopes
Characters
athanlab_list_voices
The voices the key can use, official and from your library, and the default voice.
voices:read
no charge
athanlab_preview_voice
A signed sample URL of one voice, valid about an hour; with save: true it also downloads the sample.
voices:read
no charge
athanlab_quote
A dry run: what a text would cost, how many pieces it becomes and whether the balance covers it.
speech:write
no charge
athanlab_speak
One text to one audio file: creates the job, polls it and downloads the audio.
speech:write, speech:read
spends characters
athanlab_speak_script
A script of lines to one file per line, manifest.json and optionally combined.wav. Quotes first; re-running it resumes.
speech:write, speech:read (with usage:read: runs several lines at once)
spends characters
athanlab_send_to_inbox
A title and its text, or your own chapters, to your AthanInbox on athanlab.com. Charged up front, failed chapters refunded; quotes first above the thresholds; returns at once.
speech:write (with speech:read: waits for it with wait_seconds)
spends characters
athanlab_get_delivery
One delivery: status, each chapter, characters charged and refunded, and its AthanInbox link.
speech:read
no charge
athanlab_get_job
One job: status, progress, characters charged and refunded, audio details.
speech:read
no charge
athanlab_list_jobs
A page of jobs, newest first, optionally only those in one status.
speech:read
no charge
athanlab_cancel_job
Stops a job that is still processing; the unprocessed share is refunded.
speech:write
refunds characters
athanlab_download_audio
Saves a finished job’s audio, as MP3 or WAV, into the output folder.
speech:read
no charge
athanlab_usage
Plan, balance, this key’s monthly budget and the jobs running against the limit.
usage:read
no charge
It also serves the athanlab-writing-guide prompt (and the resource athanlab://guides/writing): the rules of Writing text for speech, for the agent to read before it writes text, or to rewrite a draft you give it.
Finish a job, don’t repeat it
A job keeps running after the client stops waiting for it. Finish it with athanlab_get_job and athanlab_download_audio, or by calling the same script again, and check a delivery with athanlab_get_delivery instead of sending it again. Speaking the same text again pays twice.
AthanInbox
Send audio to your own AthanInbox, and listen on athanlab.com.
AthanInbox is the inbox of your AthanLab account, on the web at athanlab.com/dashboard/inbox. AthanInbox comes to the AthanLab mobile app in a later update. Instead of saving files, an agent with athanlab_send_to_inbox, or your own code with POST /deliveries, sends a title and its text there. You listen when it suits you, chapter by chapter, with a transcript that follows along and the source links that came with it. A delivery always goes to AthanInbox of the account that owns the key, never to anyone else.
Charged up front
The whole delivery, every chapter, is charged when it is accepted, and the response is 202 Accepted at once. A chapter that cannot be made is refunded automatically, to the balance it was charged from (a monthly allowance that has reset since is not carried over), and the other chapters still arrive.
Made in the background
The chapters are made one at a time, in order, after the response. A chapter still waiting after 7 days expires and is refunded.
Listen on athanlab.com
Sign in to athanlab.com with the account that owns the key and open AthanInbox. Progress shows there while the chapters are made. app_url, https://athanlab.com/app/inbox/<id>, opens the delivery in AthanInbox for the signed-in owner only. Open it on a computer for now: on a phone with the AthanLab app installed, the link opens the app, which gets AthanInbox in a later update. On a phone, open athanlab.com/dashboard/inbox in the browser.
Kept until you delete it
A job's audio is deleted after 30 days, but a delivery stays in AthanInbox until you delete it there or delete your account. Deleting one while it is being made refunds every chapter not yet made and the unmade share of the one in progress.
A delivery is charged when it is accepted
Every chapter is paid for before any of its audio exists, and only a chapter that fails is refunded. Quote a large delivery first with "dry_run": true, and give an agent a key with a monthly budget (see A key for each agent).
Delivery statuses, what each means and what happens to its characters
Status
Meaning
Characters
queued
Accepted and charged. No chapter has started yet.
Charged
processing
The chapters are being made. progress rises from 0 toward 1.
Charged
succeeded
Every chapter is ready in AthanInbox.
Charged
partially_succeeded
Some chapters are ready. Each failed chapter's error.code says why (see the codes below).
Failed chapters refunded
failed
No chapter could be made.
Refunded, automatically
deleted
You deleted it in AthanInbox. Only its charge and refunds stay readable.
Chapters not yet made were refunded
A delivery takes up to 50 chapters of at most 5,000 characters each, 100,000 in total, a title of up to 120 characters and up to 20 https source links, which AthanInbox shows and AthanLab never fetches. Each chapter is checked like the text of a job before anything is charged (see Writing text for speech). An account can have 3 deliveries unfinished at once.
Send an Idempotency-Key with every POST /deliveries (see Safe retries), and add "dry_run": true for a free DeliveryQuote (see Quote first with dry_run). GET /deliveries/{id} reports each chapter and what the delivery cost. Polling is optional, since AthanInbox shows the progress; if you poll, wait at least the Retry-After of the create response (30 seconds) between polls.
A failed chapter reports one of these codes in its error.code, and its characters are refunded:
Delivery chapter failure codes
Code
Retryable
Meaning
input_rejected
No
The chapter text could not be turned into speech. Change it before sending it again. The characters were refunded.
capacity_unavailable
Yes
No speech capacity became available for this chapter. Send it again later. The characters were refunded.
generation_failed
Yes
Speech generation failed for this chapter. Send it again. The characters were refunded.
interrupted
Yes
The chapter was interrupted by server restarts. Send it again. The characters were refunded.
voice_unavailable
No
The voice was no longer available when the chapter ran. Send it again with another voice. The characters were refunded.
expired
Yes
The chapter waited too long to be generated. Send it again. The characters were refunded.
From an agent.athanlab_send_to_inbox takes a title and the text, and splits long text into chapters of at most 5,000 characters at paragraph and sentence ends, or takes your own chapters. Above 2,000 characters or 20 chapters it returns a free quote and sends nothing until it is called again with confirm: true. It returns at once with the delivery's id, status and app_url, and athanlab_get_delivery checks on it. The same arguments within 24 hours return the first delivery and charge nothing, while send_again: true sends a second copy, charged again. Nothing is saved on your machine: you listen in AthanInbox on athanlab.com.
Sending needs a key with the speech:write scope (speech:read to read a delivery back) and the same access as creating a job: an approved application and the Max plan (see Get access). Full details: POST /deliveries and GET /deliveries/{id} in the endpoint reference.
Configuration
The server is configured with these environment variables. For its default output folder it also reads CLAUDE_PROJECT_DIR, which Claude Code sets itself.
Environment variables of the MCP server
Variable
Required
Meaning
ATHANLAB_API_KEY
Yes
Your API key. It is never logged or returned to the model.
ATHANLAB_OUTPUT_DIR
No
Where audio and manifests are written, and nothing goes outside it. The default is an athanlab-audio folder in Claude Code’s project, or else in the server’s working directory (for Codex CLI, the session folder). A single file never overwrites another (a clashing name gets a suffix), but a script re-run updates the files in its own out_subdir.
ATHANLAB_MAX_CHARS_PER_CALL
No
An optional cap: a speak, script or send call with more characters is rejected before anything is sent.
ATHANLAB_API_BASE
No
The API base URL. Default https://api.athanlab.com/api/v1.
ATHANLAB_LOG_LEVEL
No
debug for more detail in the server’s log (stderr, which the client keeps). The key is removed from every log line.
A key for each agent
An agent spends real characters on its own, so give it a key you can cap and switch off. When you create it on the Developer API page:
A separate key for each agent or machine, named for where it runs, so you can see what it spends and revoke it without touching your servers.
Only the scopes it needs. All four for everything the server does. Without speech:write it can list voices, read jobs and deliveries, download audio and check usage, but not create or cancel jobs or send to AthanInbox.
A low monthly character budget, for example 20,000. Past it, creating a job returns 429 key_budget_exceeded and nothing is charged, so a looping agent cannot drain your balance.
A short expiry: 30 days, the shortest there is. Create a new key when it runs out.
Keep it out of prompts and repositories. The key belongs in the server's environment. The model never needs to see it, so never paste it into a chat.
Rotate or revoke. If a key leaks, revoke it at once, or rotate it and choose Stop the old secret immediately (see Authentication).
The server checks the key's format on your machine before any request, so a malformed key (a missing prefix, a truncated paste, the placeholder) never reaches the API or counts toward the per-network limit on failed key checks. A well-formed but wrong key is sent once. After one 401 invalid_api_key, the server sends nothing more until it restarts with a new key.
Spending brakes.athanlab_speak_script returns its quote and creates nothing above 2,000 characters or 20 lines until it is called again with confirm: true. athanlab_send_to_inbox asks the same way above 2,000 characters or 20 chapters. ATHANLAB_MAX_CHARS_PER_CALL caps any single call.
Example: a two-speaker scene
Ask the agent in plain words, with the script:
Voice this scene into scene01 with a female voice for Aye and a male voice for Min, combine it into one track, then write scene01.srt from the manifest.
A re-run with the same out_subdir skips finished lines without paying again. Lines are matched by position: inserting or removing a line regenerates, and charges, every line after it, and changing the output format regenerates them all. Add new lines at the end, or give a new version its own out_subdir.
In scene01/ inside the output folder (athanlab-audio by default) it writes one WAV file per line (001-l1.wav to 004-l4.wav), manifest.json and, with combine, combined.wav. The manifest puts every line on one timeline:
Each line starts gaps.turn seconds (0.55 by default) after the previous one ends when the speaker changes, and gaps.sentence seconds (0.4) when the same speaker goes on. combined.wav has exactly these gaps as silence, so the manifest's times are times in it. The real file also gives each line its job_id and status.
Gaps are measured between files
The API does not report speech timing yet, so gaps are measured file edge to file edge. A file can begin and end with a little silence, which makes the pause you hear slightly longer than the gap, and a subtitle cue starts with its file rather than its first word.
From the manifest the agent writes one subtitle cue per line, from start_seconds to end_seconds:
Use combined.wav as the voice track, or place each line's file at its start_seconds in your editor. This ffmpeg command puts the track under a video and keeps the video's full length:
Accepts and charges a job, then returns 202 Accepted at once — the audio is generated in the background.
More about this endpoint
Poll the job at the Location header: wait this response's Retry-After (2 seconds) before the first poll, then back off to about 10 seconds between polls until it finishes (a successful poll sends no Retry-After of its own), then download its audio (https://athanlab.com/docs#polling).
Writing text that sounds natural — sentence punctuation, Burmese and English (made-up names spelled the way they sound in Burmese), and what each number_mode reads: https://athanlab.com/docs#writing-text.
A unique string per request (a UUID works). A retry with the same key and body within 24 hours returns what the first request created — the original job or delivery — without a new charge; the same key with a different body is rejected with 409 idempotency_conflict. A key used on POST /speech never conflicts with the same value on POST /deliveries. Ignored for dry runs.
1–255 characters · pattern ^[\x20-\x7E]{1,255}$
Body (JSON; unknown fields are rejected)
text
stringrequired
The text to speak, 1–5,000 characters, billed at that same count. Characters are UTF-16 code units (JavaScript String.length): a Burmese or Latin character counts 1, an emoji or any other character outside the Basic Multilingual Plane counts 2. maxLength counts code points, so a client-side check can pass text the API rejects; dry_run returns the exact count.
1–5,000 characters
voice_id
string | nulloptional
A voice from GET /voices. Defaults to the API default voice (default_voice_id).
1–128 characters
output_format
"mp3" | "wav"optional
The audio format the job prepares. Either format can still be downloaded later.
Stops a job that is still processing. The share already generated stays charged; the rest is refunded (usage.characters_refunded).
More about this endpoint
Cancelling a cancelled job returns it again. Works in read-only mode and after your Max plan ends, because it only ever returns characters. No request body.
Streams the finished audio. Defaults to the job's output_format; ?format= downloads the other one (MP3 is encoded on first request, so the first WAV→MP3 download of a job can take a few seconds).
More about this endpoint
HEAD returns the same status and headers as GET — Content-Length and ETag included — without the body, so it also returns 410 once the audio is gone. Until an MP3 has been encoded it has no size or ETag yet, so HEAD of a wav job's first MP3 omits both. Audio is kept for 30 days (audio.expires_at).
Send audio to AthanInbox, the key owner's inbox on athanlab.com: a titled text or up to 50 chapters, made in the background and kept in AthanInbox until the owner deletes it.
Sends audio to AthanInbox, the inbox on athanlab.com of the account that owns the key — never anyone else's.
More about this endpoint
Accepts and charges the whole delivery, every chapter, then returns 202 Accepted at once: the chapters are made in the background, one at a time, and the owner follows their progress and listens in AthanInbox at https://athanlab.com/dashboard/inbox. A chapter that cannot be made is refunded to the balance it was charged from (a monthly allowance that has reset since is not carried over; usage.characters_refunded shows what came back) and the rest still arrive (partially_succeeded). The delivery stays in AthanInbox until the owner deletes it.
Polling is optional. To poll, GET the delivery at the Location header no more often than this response's Retry-After (30 seconds).
Send the content as text (one chapter) or as chapters — up to 50 of at most 5,000 characters each, 100,000 in total — each checked like a speech job's text before anything is charged (an error names the chapter, as in chapters.3.text). The charge is the total length in UTF-16 code units. Send an Idempotency-Key header so a retry never charges twice (https://athanlab.com/docs#idempotency). With dry_run: true the response is a 200 quote, and nothing is created or charged. Guide: https://athanlab.com/docs#athaninbox.
A unique string per request (a UUID works). A retry with the same key and body within 24 hours returns what the first request created — the original job or delivery — without a new charge; the same key with a different body is rejected with 409 idempotency_conflict. A key used on POST /speech never conflicts with the same value on POST /deliveries. Ignored for dry runs.
1–255 characters · pattern ^[\x20-\x7E]{1,255}$
Body (JSON; unknown fields are rejected)
title
stringrequired
What the listener sees in AthanInbox, 1–120 characters. Leading and trailing spaces are removed; line breaks and other control characters are refused.
1–120 characters
text
stringoptional
The text of a one-chapter delivery, 1–5,000 characters. Send exactly one of text and chapters.
1–5,000 characters
chapters
object[]optional
1–50 chapters, played in this order, totalling at most 100,000 characters. Send exactly one of text and chapters.
voice_id
string | nulloptional
A voice from GET /voices, used for every chapter. Defaults to the API default voice (default_voice_id).
The delivery's current state, its chapters and what it cost. The owner follows its progress in AthanInbox, so polling is optional; to poll, wait at least 30 seconds between requests.
More about this endpoint
After the owner deletes it in AthanInbox it reads as deleted, keeping only its charge and refunds. A delivery of another account returns 404.
The balance your keys draw on — the same one the AthanLab app uses: the Max plan's monthly allowance first, then Athan Tokens. entitled says whether keys may create jobs right now.
More about this endpoint
Also the two limits a create is checked against besides the balance: the account's jobs processing (active_jobs of max_concurrent_jobs) and the calling key's monthly budget (key). Works after your Max plan ends.
Machine-readable reason, explained under "Job failure codes" at https://athanlab.com/docs#jobs. Not an HTTP error code: do not look it up as #errors-<code> — capacity_unavailable is also an HTTP 503, which charged nothing and carries Retry-After, while a failed job was charged, then refunded.
message
string
A human-readable explanation. Do not parse it.
retryable
boolean
True when submitting the same request again may succeed.
SpeechAudio
format
"mp3" | "wav"
The job's output format. GET the audio URL with ?format= for the other one.
duration_seconds
number
Length of the audio in seconds.
at least 0
url
string
Download path, relative to https://api.athanlab.com. Send your API key with the request.
expires_at
string (ISO 8601)
When the audio (and the job's input text) is deleted. Downloads return 410 audio_expired after this.
queued until its first chapter starts, processing while chapters are made, then succeeded (every chapter), partially_succeeded (some chapters failed and were refunded) or failed (none could be made; all refunded). deleted once the owner deleted it in AthanInbox.
title
string | null
The title the owner sees in AthanInbox; null once the delivery is deleted.
When its last chapter finished; null while queued or processing.
progress
number | null
Share of the delivery made, by characters: 0 to 0.99 while queued or processing, 1 once it succeeded or partially succeeded, null once it failed or was deleted.
0–1
input
object
What was sent. The text itself is never returned.
input.characters
integer
Characters charged: the total length of the chapters in UTF-16 code units (an emoji counts 2).
greater than 0
input.chapters
integer
How many chapters were sent.
greater than 0
input.voice_id
string | null
The voice every chapter speaks with (the API default voice when the request named none).
The links sent with the delivery, as AthanInbox shows them; empty once it is deleted.
usage
object
What the delivery cost.
usage.characters_charged
integer
Characters charged when the delivery was accepted: every chapter, up front.
at least 0
usage.characters_refunded
integer
Characters returned to the balance since: each failed chapter in full and, when the owner deleted the delivery while it was being made, every chapter not yet made plus the unmade share of the one in progress. A share charged to a monthly allowance that has reset since is not returned (the reset gave a fresh allowance).
at least 0
app_url
string | null
The delivery in AthanInbox on athanlab.com; it opens only for the signed-in owner. Open it on a computer for now: the AthanLab mobile app gets AthanInbox in a later update. Null once deleted.
notified
boolean
True once AthanLab has recorded its notice to the owner that the delivery finished. It is not a read receipt.
metadata
map<string, string>
The metadata sent with the request, unchanged; empty once the delivery is deleted.
DeliveryChapter
index
integer
The chapter's position, from 0, in the order sent.
at least 0
title
string | null
The chapter's title as sent; null when none was sent.
queued, then processing while it is made, then succeeded or failed (refunded). cancelled when AthanLab stopped the chapter while it was being made; the share not made is refunded.
duration_seconds
number | null
Length of the chapter's audio in seconds; null until it succeeded.
Machine-readable reason: the job failure codes explained at https://athanlab.com/docs#jobs, plus voice_unavailable (the voice was removed before the chapter ran) and expired (it waited past the queue limit). Not an HTTP error code.
message
string
A human-readable explanation. Do not parse it.
retryable
boolean
True when sending the chapter again may succeed.
DeliverySource
title
string | null
What the link is; null when none was sent.
url
string
The https link, normalised (punycode host, percent-encoded path). AthanLab never fetches it.
Every error the API sends has the same JSON shape and an X-Request-Id header. Handle errors by four rules:
Branch on error.code, never on the message.
Retry only when error.retryable is true.
If the response has a Retry-After header, wait that many seconds before you retry.
If error.retryable is false, change the request before you send it again.
Error response
{
"error": {
"type": "rate_limit_error",
"code": "concurrency_limit",
"message": "This account already has the maximum number of jobs running. Retry after one finishes (see Retry-After).",
"param": null,
"retryable": true,
"request_id": "req_4f1c2a9e0b7d6c5a3e8f1b2c",
"doc_url": "https://athanlab.com/docs#errors-concurrency_limit",
"details": {
"active_jobs": 2,
"max_concurrent_jobs": 2
}
}
}
A 503 can arrive as an HTML 504
The API runs on DigitalOcean App Platform, which replaces every 503 response with its own HTML 504 page. Only Retry-After, X-Request-Id and an x-do-orig-status of 503 survive. Treat such a 504 as retryable: nothing was charged, so wait the Retry-After and retry.
Machine-readable error code. New codes may be added; handle an unknown one by its HTTP status and retryable.
error.message
string
A human-readable explanation. Do not parse it.
error.param
string | null
The request field or header at fault, when there is one.
error.retryable
boolean
True when the same request may succeed later without changes (honor Retry-After).
error.request_id
string
Also sent as the X-Request-Id header. Quote it when you contact support.
pattern ^req_[0-9a-f]{24}$
error.doc_url
string
This error's entry in the documentation.
error.details
objectmay be absent
Structured context some errors carry, such as notice (a maintenance message), replacement (a removed endpoint's successor), required_scope, job_id (idempotency_conflict), active_jobs / max_concurrent_jobs (concurrency_limit), required / spendable (insufficient_characters) or required / monthly_char_budget / used_this_month / resets_at (key_budget_exceeded). On a delivery: delivery_id (idempotency_conflict) and active_deliveries / max_active_deliveries (concurrency_limit). New keys may be added.
error.upgrade_url
stringmay be absent
Where to fix a billing error.
Request IDs
Every response carries an X-Request-Id header, and an error repeats it as error.request_id. Quote it when you contact support.
Error codes
The codes are grouped by error.type. For a code not listed here, see Versioning.
Each limit applies per key, per account or per network, and each row says which. Your account is your AthanLab account: all its keys share the account limits.
Rate limits
Rate limits
Limit
Value
Writes (POST: create, dry run, cancel)
60 requests a minute per key (default)
Reads (GET)
600 requests a minute per key
Per network
600 requests a minute per client address, counting every request — signed with a key or not — across all your keys
Failed key checks
20 in 5 minutes block the network for 10 minutes: 429 auth_blocked
Every authenticated response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds). They describe the key's bucket that counted the request: reads and writes have separate buckets.
The per-network limit is shared
The per-network limit counts every request from your address and shows its headers only on the 429 it causes, so X-RateLimit-Remaining can be above 0 when it refuses a request. Several keys, or reads and writes together, from one address share that one budget.
Every retryable 429 carries Retry-After. Wait that long, and add random jitter when several workers share a key or network. 429 key_budget_exceeded is not retryable: the key has spent its monthly budget (see Per-key budgets).
Size and retention
Size and retention limits
Limit
Value
Text per job
5,000 characters (10,000 once numbers are written out as words). Characters are UTF-16 code units: a Burmese or Latin character counts 1, an emoji counts 2.
Request body
128 KB (1 MB for POST /deliveries)
Delivery
Up to 50 chapters of 5,000 characters each, 100,000 in total; a title of up to 120 characters; up to 20 source links. A chapter still waiting after 7 days expires and is refunded.
Metadata
16 keys of up to 40 characters (ASCII letters A–Z and a–z, digits and _ . : -, starting with a letter or digit), values up to 500 characters, 8,000 bytes in total
List page size
Up to 100 jobs (default 20)
Job deadline
45 minutes. A job that cannot finish in time fails as capacity_unavailable and is refunded.
Within /api/v1, changes are additive only: new endpoints, optional request fields, response fields, error codes and job-failure codes. Build clients that ignore fields they do not know. Handle a code you do not know by its retryable flag and, for an HTTP error, its status.
Anything that could break a working integration ships as a new version. Every key owner hears about it by email at least 90 days before the old behavior is removed.
Changelog
AthanInbox: POST /deliveries and GET /deliveries/{id} send audio to the key owner’s AthanInbox on athanlab.com (see AthanInbox). An additive change: the version stays 1.0.0.
1.0.0: asynchronous speech jobs (/speech), voices, usage and the OpenAPI document.
Retired endpoints
The earlier preview endpoints POST /generate and GET /balance are retired and return 410 endpoint_removed. Sign-in-token access has been removed too: send an API key. Any other credential returns 401 invalid_api_key and counts toward the per-network limit on failed key checks.