API v1

Developer API

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 URL https://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

  1. Create. POST /speech returns 202 Accepted with the job.
  2. Poll. GET /speech/{id} until its status is no longer processing, as described in Polling.
  3. 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
ItemValue
Base URLhttps://api.athanlab.com/api/v1
AuthenticationAn API key in X-API-Key or Authorization: Bearer. See Authentication.
FormatJSON over HTTPS
OutputMP3 or WAV
Text per jobUp to 5,000 characters. See Rate limits and quotas.
Audio kept30 days
AthanInboxSends audio to your AthanInbox on athanlab.com, where it stays until you delete it. See AthanInbox.
SpecificationOpenAPI 3.1. There is no official client library. Generate one from the OpenAPI document.
TermsAPI Terms · Terms of Service · Acceptable Use Policy

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.

  1. 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.

  2. 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.

  3. Subscribe to Max

    To pay with KBZPay instead of a card, see Billing.

  4. 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.

Send an API key with every request except GET /openapi.json.

Request headers
X-API-Key: $ATHANLAB_API_KEY
# or
Authorization: Bearer $ATHANLAB_API_KEY

For example, this request returns your balance. It needs the usage:read scope (see Scopes).

Shell
curl -sS https://api.athanlab.com/api/v1/usage \
  -H "X-API-Key: $ATHANLAB_API_KEY"

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:

A request outside a key's scopes returns 403 scope_missing.

Rotate a key

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.
  1. Create a job

    Request
    curl -sS https://api.athanlab.com/api/v1/speech \
      -H "X-API-Key: $ATHANLAB_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"text": "မင်္ဂလာပါ။ အသံလက်ဘ်မှ ကြိုဆိုပါတယ်။", "output_format": "mp3",
        "metadata": {"order_id": "A-1042"}}'
    202 Accepted (example)
    {
      "object": "speech",
      "id": "8f14e45fceea167a5a36dedd4bea2543",
      "status": "processing",
      "created_at": "2026-10-01T08:30:00.000Z",
      "completed_at": null,
      "progress": 0,
      "input": {
        "characters": 35,
        "voice_id": "athanlab-default-female-v1",
        "output_format": "mp3",
        "number_mode": "smart"
      },
      "usage": {
        "characters_charged": 35,
        "characters_refunded": 0
      },
      "audio": null,
      "error": null,
      "metadata": {
        "order_id": "A-1042"
      },
      "balance": {
        "spendable_chars": 1949965
      }
    }

    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).

  2. Poll the job

    Put the id from the response in JOB_ID, then fetch the job.

    Request
    curl -sS https://api.athanlab.com/api/v1/speech/$JOB_ID \
      -H "X-API-Key: $ATHANLAB_API_KEY"
    200 OK (example)
    {
      "object": "speech",
      "id": "8f14e45fceea167a5a36dedd4bea2543",
      "status": "succeeded",
      "created_at": "2026-10-01T08:30:00.000Z",
      "completed_at": "2026-10-01T08:30:07.412Z",
      "progress": 1,
      "input": {
        "characters": 35,
        "voice_id": "athanlab-default-female-v1",
        "output_format": "mp3",
        "number_mode": "smart"
      },
      "usage": {
        "characters_charged": 35,
        "characters_refunded": 0
      },
      "audio": {
        "format": "mp3",
        "duration_seconds": 3.84,
        "url": "/api/v1/speech/8f14e45fceea167a5a36dedd4bea2543/audio",
        "expires_at": "2026-10-31T08:30:00.000Z"
      },
      "error": null,
      "metadata": {
        "order_id": "A-1042"
      }
    }

    Repeat until status is no longer processing, at the pace described in Polling.

  3. Download the audio

    Request
    curl -sS --fail -o speech.mp3 \
      https://api.athanlab.com/api/v1/speech/$JOB_ID/audio \
      -H "X-API-Key: $ATHANLAB_API_KEY"

    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:

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"

Sending your own text? Read Writing text for speech first: sentence punctuation, made-up names, and how numbers are read.

Next steps

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
ModeWhat it readsကျပ် ၁၂၃၄ is spoken as
smartThe 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.ကျပ် တစ်ထောင့်နှစ်ရာ့သုံးဆယ့်လေး
placeEvery number, in Burmese or Latin digits, as Burmese place words (up to 8 digits; longer runs digit by digit).ကျပ် တစ်ထောင့်နှစ်ရာ့သုံးဆယ့်လေး
digitsBurmese, one digit at a time: phone numbers, codes, IDs.ကျပ် တစ် နှစ် သုံး လေး
englishEnglish number words.ကျပ် one thousand two hundred thirty four
english_digitsEnglish, 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)).

A job starts as processing and ends in exactly one final status. It never moves back.

Statuses

Job statuses, what each means and what happens to its characters
StatusMeaningCharacters
processingAccepted and charged. progress rises from 0 toward 1.Charged
succeededThe audio is ready: audio.url is the download path.Charged
failederror.code says why (see Job failure codes).Refunded in full, automatically
cancelledYou cancelled it.The unprocessed share is refunded

Polling

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:

Request
curl -sS "https://api.athanlab.com/api/v1/speech?status=processing&limit=50" \
  -H "X-API-Key: $ATHANLAB_API_KEY"

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:

Request
curl -sS https://api.athanlab.com/api/v1/speech \
  -H "X-API-Key: $ATHANLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text": "မင်္ဂလာပါ။ အသံလက်ဘ်မှ ကြိုဆိုပါတယ်။", "dry_run": true}'
200 OK
{
  "object": "speech.quote",
  "characters": 35,
  "dispatches": 1,
  "spendable": 1950000,
  "sufficient": true
}

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
CodeRetryableMeaning
input_rejectedNoThe text could not be turned into speech. Change the input before retrying. The characters were refunded.
capacity_unavailableYesNo speech capacity became available before the job deadline. Retry later. The characters were refunded.
generation_failedYesSpeech generation failed. Retry the request. The characters were refunded.
interruptedYesThe 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).

Full details: GET /voices and GET /voices/{id}/preview in the endpoint reference.

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
EventCharacters
Accepted jobThe length of its text in UTF-16 code units (see Size and retention), charged when the job is accepted
Failed jobRefunded in full, automatically
Cancelled jobThe generated share stays charged. The rest is refunded.
Delivery to AthanInboxThe 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 madeChapters already made stay charged, and the chapter in progress only for its generated share. The rest is refunded.
Dry run, idempotent replay or rejected requestNothing
Numbers written out as wordsNothing extra (see Numbers)
Not enough charactersNothing: creating the job or delivery returns 402 insufficient_characters

Per-key budgets

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

Install

terminal
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.

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.

terminal
mkdir -p ~/.claude/skills/athanlab-voice
curl -fsSL https://unpkg.com/@athanlab/mcp/skill/athanlab-voice/SKILL.md \
  -o ~/.claude/skills/athanlab-voice/SKILL.md

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
ToolWhat it doesScopesCharacters
athanlab_list_voicesThe voices the key can use, official and from your library, and the default voice.voices:readno charge
athanlab_preview_voiceA signed sample URL of one voice, valid about an hour; with save: true it also downloads the sample.voices:readno charge
athanlab_quoteA dry run: what a text would cost, how many pieces it becomes and whether the balance covers it.speech:writeno charge
athanlab_speakOne text to one audio file: creates the job, polls it and downloads the audio.speech:write, speech:readspends characters
athanlab_speak_scriptA 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_inboxA 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_deliveryOne delivery: status, each chapter, characters charged and refunded, and its AthanInbox link.speech:readno charge
athanlab_get_jobOne job: status, progress, characters charged and refunded, audio details.speech:readno charge
athanlab_list_jobsA page of jobs, newest first, optionally only those in one status.speech:readno charge
athanlab_cancel_jobStops a job that is still processing; the unprocessed share is refunded.speech:writerefunds characters
athanlab_download_audioSaves a finished job’s audio, as MP3 or WAV, into the output folder.speech:readno charge
athanlab_usagePlan, balance, this key’s monthly budget and the jobs running against the limit.usage:readno 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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
StatusMeaningCharacters
queuedAccepted and charged. No chapter has started yet.Charged
processingThe chapters are being made. progress rises from 0 toward 1.Charged
succeededEvery chapter is ready in AthanInbox.Charged
partially_succeededSome chapters are ready. Each failed chapter's error.code says why (see the codes below).Failed chapters refunded
failedNo chapter could be made.Refunded, automatically
deletedYou 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
CodeRetryableMeaning
input_rejectedNoThe chapter text could not be turned into speech. Change it before sending it again. The characters were refunded.
capacity_unavailableYesNo speech capacity became available for this chapter. Send it again later. The characters were refunded.
generation_failedYesSpeech generation failed for this chapter. Send it again. The characters were refunded.
interruptedYesThe chapter was interrupted by server restarts. Send it again. The characters were refunded.
voice_unavailableNoThe voice was no longer available when the chapter ran. Send it again with another voice. The characters were refunded.
expiredYesThe 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
VariableRequiredMeaning
ATHANLAB_API_KEYYesYour API key. It is never logged or returned to the model.
ATHANLAB_OUTPUT_DIRNoWhere 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_CALLNoAn optional cap: a speak, script or send call with more characters is rejected before anything is sent.
ATHANLAB_API_BASENoThe API base URL. Default https://api.athanlab.com/api/v1.
ATHANLAB_LOG_LEVELNodebug 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.

Aye: မင်္ဂလာပါ။ နေကောင်းလား။ Min: နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။ Min: ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။ Aye: ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။

It calls athanlab_speak_script with one entry per line and a voice for each speaker:

athanlab_speak_script arguments
{
  "lines": [
    { "id": "l1", "speaker": "Aye", "text": "မင်္ဂလာပါ။ နေကောင်းလား။" },
    { "id": "l2", "speaker": "Min", "text": "နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။" },
    { "id": "l3", "speaker": "Min", "text": "ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။" },
    { "id": "l4", "speaker": "Aye", "text": "ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။" }
  ],
  "voices": { "Aye": "athanlab-default-female-v1", "Min": "athanlab-default-male-v1" },
  "out_subdir": "scene01",
  "combine": true
}

Re-runs match lines by position

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:

manifest.json (trimmed)
{
  "version": 1,
  "output_format": "wav",
  "gaps": { "sentence": 0.4, "turn": 0.55 },
  "timeline_complete": true,
  "total_duration_seconds": 10.8,
  "combined_file": "combined.wav",
  "lines": [
    {
      "index": 0, "id": "l1", "speaker": "Aye", "voice_id": "athanlab-default-female-v1",
      "text": "မင်္ဂလာပါ။ နေကောင်းလား။", "file": "001-l1.wav", "characters_charged": 23,
      "duration_seconds": 2.1, "start_seconds": 0, "end_seconds": 2.1
    },
    {
      "index": 1, "id": "l2", "speaker": "Min", "voice_id": "athanlab-default-male-v1",
      "text": "နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။", "file": "002-l2.wav", "characters_charged": 31,
      "duration_seconds": 2.4, "start_seconds": 2.65, "end_seconds": 5.05
    },
    {
      "index": 2, "id": "l3", "speaker": "Min", "voice_id": "athanlab-default-male-v1",
      "text": "ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။", "file": "003-l3.wav", "characters_charged": 26,
      "duration_seconds": 2.2, "start_seconds": 5.45, "end_seconds": 7.65
    },
    {
      "index": 3, "id": "l4", "speaker": "Aye", "voice_id": "athanlab-default-female-v1",
      "text": "ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။", "file": "004-l4.wav", "characters_charged": 29,
      "duration_seconds": 2.6, "start_seconds": 8.2, "end_seconds": 10.8
    }
  ]
}

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:

scene01.srt
1
00:00:00,000 --> 00:00:02,100
မင်္ဂလာပါ။ နေကောင်းလား။

2
00:00:02,650 --> 00:00:05,050
နေကောင်းပါတယ်။ ကျေးဇူးတင်ပါတယ်။

3
00:00:05,450 --> 00:00:07,650
ဒီနေ့ ရာသီဥတု သာယာတယ်နော်။

4
00:00:08,200 --> 00:00:10,800
ဟုတ်တယ်။ လမ်းလျှောက်ကြရအောင်။

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:

Shell
ffmpeg -i video.mp4 -i combined.wav -map 0:v -map 1:a -c:v copy -af apad -c:a aac -shortest out.mp4

Burned-in subtitles need a font with Burmese glyphs.

This reference is generated from the API's own OpenAPI document: each endpoint lists its parameters, responses, errors and an example.

Every path is relative to the base URL https://api.athanlab.com/api/v1, and client generators can read this reference as an OpenAPI 3.1 document.

The response headers listed below leave out X-Request-Id and the X-RateLimit-* headers, which are covered in Request IDs and Rate limits and quotas.

Endpoints at a glance

Every endpoint, with the scope it needs
MethodPathSummaryScope
POST/speechCreate a speech jobspeech:write
GET/speechList speech jobsspeech:read
GET/speech/{id}Get a speech jobspeech:read
POST/speech/{id}/cancelCancel a speech jobspeech:write
GET/speech/{id}/audioDownload a job's audiospeech:read
POST/deliveriesSend to AthanInboxspeech:write
GET/deliveries/{id}Get a deliveryspeech:read
GET/voicesList voicesvoices:read
GET/voices/{id}/previewGet a voice samplevoices:read
GET/usageGet the account balanceusage:read
GET/openapi.jsonThis OpenAPI documentNo key needed

Speech

Asynchronous speech jobs: create, poll, download, cancel.

Create a speech job

POST https://api.athanlab.com/api/v1/speech

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).

The charge is the length of text in UTF-16 code units (https://athanlab.com/docs#size-limits). 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 (https://athanlab.com/docs#dry-run).

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.

See: Polling · Safe retries · Quote first · Writing text

Headers
Idempotency-Key
stringoptional
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.

default mp3

number_mode
"smart" | "place" | "digits" | "english" | "english_digits"optional
How digits are read: smart (Burmese digits as words, other digits as written), place, digits, english, english_digits.

default smart

metadata
map<string, string>optional
Up to 16 string key/value pairs stored with the job and returned on every read (keys ≤ 40 characters, values ≤ 500, ≤ 8000 bytes in total).

keys: 1–40 characters, pattern ^[A-Za-z0-9][A-Za-z0-9_.:-]*$ · values: at most 500 characters

dry_run
booleanoptional
Validate and quote the request without creating a job or charging anything.
Returns
  • 202 Accepted CreateSpeechResponse

    The job was accepted and charged, or an earlier request with the same Idempotency-Key was replayed.

    Headers: Location, Retry-After, Idempotent-Replayed

  • 200 OK DryRunQuote

    A quote for a dry_run request. Nothing was created or charged.

Example
Request
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": "မင်္ဂလာပါ။ အသံလက်ဘ်မှ ကြိုဆိုပါတယ်။",
    "voice_id": "athanlab-default-female-v1",
    "output_format": "mp3",
    "metadata": {
      "order_id": "A-1042"
    }
  }'
202 Accepted · CreateSpeechResponse
{
  "object": "speech",
  "id": "8f14e45fceea167a5a36dedd4bea2543",
  "status": "processing",
  "created_at": "2026-10-01T08:30:00.000Z",
  "completed_at": null,
  "progress": 0,
  "input": {
    "characters": 35,
    "voice_id": "athanlab-default-female-v1",
    "output_format": "mp3",
    "number_mode": "smart"
  },
  "usage": {
    "characters_charged": 35,
    "characters_refunded": 0
  },
  "audio": null,
  "error": null,
  "metadata": {
    "order_id": "A-1042"
  },
  "balance": {
    "spendable_chars": 1949965
  }
}
200 OK · DryRunQuote
{
  "object": "speech.quote",
  "characters": 35,
  "dispatches": 1,
  "spendable": 1950000,
  "sufficient": true
}

List speech jobs

GET https://api.athanlab.com/api/v1/speech

Jobs created by any of the account's keys, newest first. Page with cursor (the previous page's next_cursor, unchanged) until has_more is false.

Query parameters
status
"processing" | "succeeded" | "failed" | "cancelled"optional
Only jobs in this status.
limit
integeroptional
Jobs per page, 1–100.

1–100 · default 20

cursor
stringoptional
The next_cursor of the previous page.
Returns
Example
Request
curl -sS https://api.athanlab.com/api/v1/speech \
  -H "X-API-Key: $ATHANLAB_API_KEY"
Show the 200 OK response
200 OK
{
  "object": "list",
  "data": [
    {
      "object": "speech",
      "id": "8f14e45fceea167a5a36dedd4bea2543",
      "status": "succeeded",
      "created_at": "2026-10-01T08:30:00.000Z",
      "completed_at": "2026-10-01T08:30:07.412Z",
      "progress": 1,
      "input": {
        "characters": 35,
        "voice_id": "athanlab-default-female-v1",
        "output_format": "mp3",
        "number_mode": "smart"
      },
      "usage": {
        "characters_charged": 35,
        "characters_refunded": 0
      },
      "audio": {
        "format": "mp3",
        "duration_seconds": 3.84,
        "url": "/api/v1/speech/8f14e45fceea167a5a36dedd4bea2543/audio",
        "expires_at": "2026-10-31T08:30:00.000Z"
      },
      "error": null,
      "metadata": {
        "order_id": "A-1042"
      }
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Get a speech job

GET https://api.athanlab.com/api/v1/speech/{id}

The job's current state. Poll it while status is processing, as https://athanlab.com/docs#polling describes.

More about this endpoint

A job of another account returns 404.

See: Polling

Path parameters
id
stringrequired
The job id from the create response.

pattern ^[0-9a-f]{32}$

Returns
Example
Request
curl -sS https://api.athanlab.com/api/v1/speech/$JOB_ID \
  -H "X-API-Key: $ATHANLAB_API_KEY"
200 OK
{
  "object": "speech",
  "id": "8f14e45fceea167a5a36dedd4bea2543",
  "status": "succeeded",
  "created_at": "2026-10-01T08:30:00.000Z",
  "completed_at": "2026-10-01T08:30:07.412Z",
  "progress": 1,
  "input": {
    "characters": 35,
    "voice_id": "athanlab-default-female-v1",
    "output_format": "mp3",
    "number_mode": "smart"
  },
  "usage": {
    "characters_charged": 35,
    "characters_refunded": 0
  },
  "audio": {
    "format": "mp3",
    "duration_seconds": 3.84,
    "url": "/api/v1/speech/8f14e45fceea167a5a36dedd4bea2543/audio",
    "expires_at": "2026-10-31T08:30:00.000Z"
  },
  "error": null,
  "metadata": {
    "order_id": "A-1042"
  }
}

Cancel a speech job

POST https://api.athanlab.com/api/v1/speech/{id}/cancel

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.

See: Cancel

Path parameters
id
stringrequired
The job id from the create response.

pattern ^[0-9a-f]{32}$

Returns
Example
Request
curl -sS -X POST https://api.athanlab.com/api/v1/speech/$JOB_ID/cancel \
  -H "X-API-Key: $ATHANLAB_API_KEY"
200 OK
{
  "object": "speech",
  "id": "8f14e45fceea167a5a36dedd4bea2543",
  "status": "cancelled",
  "created_at": "2026-10-01T08:30:00.000Z",
  "completed_at": "2026-10-01T08:30:03.000Z",
  "progress": null,
  "input": {
    "characters": 35,
    "voice_id": "athanlab-default-female-v1",
    "output_format": "mp3",
    "number_mode": "smart"
  },
  "usage": {
    "characters_charged": 35,
    "characters_refunded": 21
  },
  "audio": null,
  "error": null,
  "metadata": {
    "order_id": "A-1042"
  }
}

Download a job's audio

GET https://api.athanlab.com/api/v1/speech/{id}/audio

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).

See: Retention

Path parameters
id
stringrequired
The job id from the create response.

pattern ^[0-9a-f]{32}$

Query parameters
format
"mp3" | "wav"optional
Download in this format instead of the job's output_format.
Returns
  • 200 OK audio/mpeg · audio/wav

    The audio file (sent as an attachment named athanlab-speech-{id}.mp3 or .wav).

    Headers: Content-Disposition, Content-Length, ETag

Example
Request
curl -sS --fail -o speech.mp3 \
  https://api.athanlab.com/api/v1/speech/$JOB_ID/audio \
  -H "X-API-Key: $ATHANLAB_API_KEY"

Deliveries

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.

Send to AthanInbox

POST https://api.athanlab.com/api/v1/deliveries

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.

See: AthanInbox · Safe retries · Writing text

Headers
Idempotency-Key
stringoptional
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).

1–128 characters

number_mode
"smart" | "place" | "digits" | "english" | "english_digits"optional
How digits are read in every chapter: smart (Burmese digits as words, other digits as written), place, digits, english, english_digits.

default smart

kind
"article" | "book" | "lesson" | "briefing" | "other"optional
What the delivery is: article, book, lesson, briefing, other. AthanInbox files and labels it by kind.

default other

sources
object[]optional
Up to 20 links the listener can open from the delivery (≤ 16,000 bytes in total). AthanLab never fetches them.
metadata
map<string, string>optional
Up to 16 string key/value pairs stored with the delivery and returned on every read (keys ≤ 40 characters, values ≤ 500, ≤ 8000 bytes in total).

keys: 1–40 characters, pattern ^[A-Za-z0-9][A-Za-z0-9_.:-]*$ · values: at most 500 characters

dry_run
booleanoptional
Validate and quote the delivery without creating it or charging anything.
Returns
  • 202 Accepted CreateDeliveryResponse

    The delivery was accepted and charged, or an earlier request with the same Idempotency-Key was replayed.

    Headers: Location, Retry-After, Idempotent-Replayed

  • 200 OK DeliveryQuote

    A quote for a dry_run request. Nothing was created or charged.

Example
Request
curl -sS https://api.athanlab.com/api/v1/deliveries \
  -H "X-API-Key: $ATHANLAB_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(python3 -c 'import uuid; print(uuid.uuid4())')" \
  -d '{
    "title": "ယနေ့ သတင်းအကျဉ်း",
    "kind": "briefing",
    "chapters": [
      {
        "title": "ရာသီဥတု",
        "text": "ရန်ကုန်မြို့တွင် မိုးအနည်းငယ် ရွာနိုင်ပါသည်။ ထီးဆောင်ထားပါ။"
      },
      {
        "title": "ဈေးကွက်",
        "text": "ဈေးကွက်များ တည်ငြိမ်နေပါသည်။ အသေးစိတ်ကို မနက်ဖြန် ပြောပြပါမယ်။"
      }
    ],
    "sources": [
      {
        "title": "မိုးလေဝသ ခန့်မှန်းချက်",
        "url": "https://example.com/weather"
      }
    ],
    "voice_id": "athanlab-default-female-v1"
  }'
Show the 202 Accepted response
202 Accepted · CreateDeliveryResponse
{
  "object": "delivery",
  "id": "1679091c5a880faf6fb5e6087eb1b2dc",
  "status": "queued",
  "title": "ယနေ့ သတင်းအကျဉ်း",
  "kind": "briefing",
  "created_at": "2026-10-06T00:30:00.000Z",
  "completed_at": null,
  "progress": 0,
  "input": {
    "characters": 121,
    "chapters": 2,
    "voice_id": "athanlab-default-female-v1",
    "number_mode": "smart"
  },
  "chapters": [
    {
      "index": 0,
      "title": "ရာသီဥတု",
      "characters": 59,
      "status": "queued",
      "duration_seconds": null,
      "error": null
    },
    {
      "index": 1,
      "title": "ဈေးကွက်",
      "characters": 62,
      "status": "queued",
      "duration_seconds": null,
      "error": null
    }
  ],
  "sources": [
    {
      "title": "မိုးလေဝသ ခန့်မှန်းချက်",
      "url": "https://example.com/weather"
    }
  ],
  "usage": {
    "characters_charged": 121,
    "characters_refunded": 0
  },
  "app_url": "https://athanlab.com/app/inbox/1679091c5a880faf6fb5e6087eb1b2dc",
  "notified": false,
  "metadata": {},
  "balance": {
    "spendable_chars": 1949879
  }
}
200 OK · DeliveryQuote
{
  "object": "delivery.quote",
  "characters": 121,
  "chapters": 2,
  "dispatches": 2,
  "spendable": 1950000,
  "sufficient": true
}

Get a delivery

GET https://api.athanlab.com/api/v1/deliveries/{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.

See: AthanInbox

Path parameters
id
stringrequired
The delivery id from the create response.

pattern ^[0-9a-f]{32}$

Returns
Example
Request
curl -sS https://api.athanlab.com/api/v1/deliveries/$DELIVERY_ID \
  -H "X-API-Key: $ATHANLAB_API_KEY"
Show the 200 OK response
200 OK
{
  "object": "delivery",
  "id": "1679091c5a880faf6fb5e6087eb1b2dc",
  "status": "succeeded",
  "title": "ယနေ့ သတင်းအကျဉ်း",
  "kind": "briefing",
  "created_at": "2026-10-06T00:30:00.000Z",
  "completed_at": "2026-10-06T00:31:24.000Z",
  "progress": 1,
  "input": {
    "characters": 121,
    "chapters": 2,
    "voice_id": "athanlab-default-female-v1",
    "number_mode": "smart"
  },
  "chapters": [
    {
      "index": 0,
      "title": "ရာသီဥတု",
      "characters": 59,
      "status": "succeeded",
      "duration_seconds": 6.48,
      "error": null
    },
    {
      "index": 1,
      "title": "ဈေးကွက်",
      "characters": 62,
      "status": "succeeded",
      "duration_seconds": 6.91,
      "error": null
    }
  ],
  "sources": [
    {
      "title": "မိုးလေဝသ ခန့်မှန်းချက်",
      "url": "https://example.com/weather"
    }
  ],
  "usage": {
    "characters_charged": 121,
    "characters_refunded": 0
  },
  "app_url": "https://athanlab.com/app/inbox/1679091c5a880faf6fb5e6087eb1b2dc",
  "notified": true,
  "metadata": {}
}

Voices

The voices a key can speak with.

List voices

GET https://api.athanlab.com/api/v1/voices

AthanLab's official voices, then the voices in your own library. default_voice_id is the voice a job uses when it names none.

More about this endpoint

Voices are added to your library in the AthanLab app, under the same consent rules as every other voice.

See: Voices

Returns
  • 200 OK VoiceList

    The voices this account can use.

Example
Request
curl -sS https://api.athanlab.com/api/v1/voices \
  -H "X-API-Key: $ATHANLAB_API_KEY"
200 OK
{
  "data": [
    {
      "id": "athanlab-default-female-v1",
      "name": "AthanLab Female",
      "category": "default",
      "source": "athanlab",
      "is_default": true
    },
    {
      "id": "athanlab-default-male-v1",
      "name": "AthanLab Male",
      "category": "default",
      "source": "athanlab",
      "is_default": false
    },
    {
      "id": "3c59dc048e8850243be8079a5c74d079",
      "name": "My narrator",
      "category": "custom",
      "source": "user",
      "is_default": false
    }
  ],
  "default_voice_id": "athanlab-default-female-v1"
}

Get a voice sample

GET https://api.athanlab.com/api/v1/voices/{id}/preview

A short-lived signed URL of the voice's reference sample, to let people hear a voice before choosing it.

See: Voices

Path parameters
id
stringrequired
A voice id from GET /voices.

1–128 characters

Returns
Example
Request
curl -sS https://api.athanlab.com/api/v1/voices/$VOICE_ID/preview \
  -H "X-API-Key: $ATHANLAB_API_KEY"
200 OK
{
  "id": "athanlab-default-female-v1",
  "name": "AthanLab Female",
  "url": "https://storage.example/athanlab-default-female-v1.wav?X-Amz-Signature=…",
  "expires_in": 3600
}

Account

The balance your keys draw on.

Get the account balance

GET https://api.athanlab.com/api/v1/usage

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.

See: Billing

Returns
Example
Request
curl -sS https://api.athanlab.com/api/v1/usage \
  -H "X-API-Key: $ATHANLAB_API_KEY"
200 OK
{
  "plan": "max",
  "entitled": true,
  "upgrade_url": null,
  "monthly": {
    "limit": 2000000,
    "used": 50000,
    "remaining": 1950000,
    "resets_at": "2026-10-21T00:00:00.000Z"
  },
  "tokens": {
    "balance": 0
  },
  "spendable": 1950000,
  "active_jobs": 1,
  "max_concurrent_jobs": 2,
  "key": {
    "id": "c4ca4238a0b923820dcc509a6f75849b",
    "monthly_char_budget": 500000,
    "used_this_month": 120000,
    "remaining": 380000,
    "resets_at": "2026-11-01T00:00:00.000Z"
  }
}

Meta

This document.

This OpenAPI document

GET https://api.athanlab.com/api/v1/openapi.json
No key needed

The OpenAPI 3.1 description of this API. Public: no key needed, and served even while the API is switched off.

Returns
  • 200 OK application/json

    The document.

    Headers: ETag

  • 304 Not Modified

    Not modified: the If-None-Match header matched the current ETag.

    Headers: ETag

Example
Request
curl -sS https://api.athanlab.com/api/v1/openapi.json

Objects

The objects the endpoints return. Ignore fields your code does not know, as Versioning explains.

SpeechJob

object
"speech"
Always speech.
id
string
The job id: 32 lowercase hex characters.
status
"processing" | "succeeded" | "failed" | "cancelled"
processing until the job finishes: succeeded, failed or cancelled.
created_at
string (ISO 8601)
When the job was accepted and charged.
completed_at
string (ISO 8601) | null
When the job finished; null while it is processing.
progress
number | null
Share of the work done: 0 to 0.99 while processing, 1 once succeeded, null once failed or cancelled.

0–1

input
object
What was requested. The input text itself is never returned.
input.characters
integer
Characters billed: the length of text in UTF-16 code units (an emoji counts 2).

greater than 0

input.voice_id
string | null
The voice the job speaks with (the API default voice when the request named none).
input.output_format
"mp3" | "wav"
The format the job prepared. Either format can be downloaded.
input.number_mode
"smart" | "place" | "digits" | "english" | "english_digits"
How digits were read.
usage
object
What the job cost.
usage.characters_charged
integer
Characters charged when the job was accepted.

at least 0

usage.characters_refunded
integer
Characters returned since: all of them when the job failed, the unprocessed share when it was cancelled.

at least 0

audio
Present while the job has downloadable audio: succeeded and inside the retention period. Null otherwise.
error
Why the job failed; null unless status is failed.
metadata
map<string, string>
The metadata sent with the request, unchanged.

SpeechJobError

code
"input_rejected" | "capacity_unavailable" | "generation_failed" | "interrupted"
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.

CreateSpeechResponse

Every field of SpeechJob, plus:

balance
object
What the account can still spend after this job's charge.
balance.spendable_chars
integer
Monthly allowance remaining plus Athan Tokens.

at least 0

SpeechList

object
"list"
Always list.
data
Jobs, newest first.
has_more
boolean
True when older jobs exist beyond this page.
next_cursor
string | null
Pass as cursor to fetch the next (older) page; null on the last page.

DryRunQuote

object
"speech.quote"
Always speech.quote.
characters
integer
What the job would be charged: the length of text in UTF-16 code units (an emoji counts 2).

greater than 0

dispatches
integer
How many pieces the text is split into for synthesis.

greater than 0

spendable
integer
Monthly allowance remaining plus Athan Tokens, right now.

at least 0

sufficient
boolean
True when spendable covers characters.

Delivery

object
"delivery"
Always delivery.
id
string
The delivery id: 32 lowercase hex characters.
status
"queued" | "processing" | "succeeded" | "partially_succeeded" | "failed" | "deleted"
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.
kind
"article" | "book" | "lesson" | "briefing" | "other"
What the delivery is, as sent.
created_at
string (ISO 8601)
When the delivery was accepted and charged.
completed_at
string (ISO 8601) | null
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).
input.number_mode
"smart" | "place" | "digits" | "english" | "english_digits"
How digits are read.
chapters
The chapters, in play order; empty once the delivery is deleted.
sources
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.
characters
integer
Characters this chapter was charged.

greater than 0

status
"queued" | "processing" | "succeeded" | "failed" | "cancelled"
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.

at least 0

error
Why the chapter failed; null unless status is failed.

DeliveryChapterError

code
"input_rejected" | "capacity_unavailable" | "generation_failed" | "interrupted" | "voice_unavailable" | "expired"
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.

CreateDeliveryResponse

Every field of Delivery, plus:

balance
object
What the account can still spend after this delivery's charge.
balance.spendable_chars
integer
Monthly allowance remaining plus Athan Tokens.

at least 0

DeliveryQuote

object
"delivery.quote"
Always delivery.quote.
characters
integer
What the delivery would be charged: the total length of the chapters in UTF-16 code units (an emoji counts 2).

greater than 0

chapters
integer
How many chapters the delivery has.

greater than 0

dispatches
integer
How many pieces the chapters are split into for synthesis, in total.

greater than 0

spendable
integer
Monthly allowance remaining plus Athan Tokens, right now.

at least 0

sufficient
boolean
True when spendable covers characters.

Voice

id
string
Pass as voice_id when creating a job.
name
string
Display name.
category
string
Voice category.
source
"athanlab" | "user"
athanlab for official voices, user for voices in your own library.
is_default
boolean
True for the voice a job uses when it names none.

VoiceList

data
Official voices first, then your own.
default_voice_id
string | null
The voice a job uses when it names none; null when that voice is temporarily unavailable (send voice_id).

VoicePreview

id
string
The voice id.
name
string
Display name.
url
string
A signed URL of the voice sample. No API key needed; it expires.
expires_in
integer
Seconds until url stops working.

greater than 0

Usage

plan
string
The account's plan.
entitled
boolean
True when keys may create jobs (the account is on Max). False means creating a job returns 402 plan_required.
upgrade_url
string | null
Where to subscribe to Max; null while entitled.
monthly
object
The plan's monthly character allowance.
monthly.limit
integer
Characters per 30-day period.

at least 0

monthly.used
integer
Characters used this period.

at least 0

monthly.remaining
integer
Characters left this period.

at least 0

monthly.resets_at
string (ISO 8601) | null
When the period resets; null without a monthly allowance.
tokens
object
Athan Tokens: purchased characters, spent after the monthly allowance.
tokens.balance
integer
Athan Tokens held.

at least 0

spendable
integer
monthly.remaining + tokens.balance.

at least 0

active_jobs
integer
The account's jobs processing right now, across all its keys.

at least 0

max_concurrent_jobs
integer
How many jobs the account may have processing at once. A create beyond it returns 429 concurrency_limit.

greater than 0

key
object
The monthly character budget of the key making this request.
key.id
string
The id of the key that made this request. A rotation keeps it, so the budget carries over.
key.monthly_char_budget
integer | null
The most this key may spend in a UTC calendar month, set by the account owner; null when the key has no budget.

greater than 0

key.used_this_month
integer | null
Characters this key has spent since the month began, net of refunds; null without a budget.

at least 0

key.remaining
integer | null
Characters left in the budget; null without a budget. A job needs both this and spendable: beyond it a create returns 429 key_budget_exceeded.

at least 0

key.resets_at
string (ISO 8601) | null
When the budget starts over (the start of the next UTC calendar month); null without a budget.

Every error the API sends has the same JSON shape and an X-Request-Id header. Handle errors by four rules:

  1. Branch on error.code, never on the message.
  2. Retry only when error.retryable is true.
  3. If the response has a Retry-After header, wait that many seconds before you retry.
  4. 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.

Error fields

error.type
"authentication_error" | "permission_error" | "billing_error" | "rate_limit_error" | "service_unavailable" | "invalid_request_error" | "api_error"
Broad category of the error.
error.code
string
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.

Error codes, grouped by error type
CodeHTTPRetryableMeaning
Authentication authentication_error
missing_api_key401NoNo API key provided. Send it in the X-API-Key header or as Authorization: Bearer <key>.
invalid_api_key401NoThe API key is invalid, revoked or expired.
Access permission_error
developer_pending403NoYour developer application is still under review. Keys start working once it is approved.
developer_rejected403NoYour developer application was not approved. See the Developer API page in your dashboard.
developer_suspended403NoDeveloper API access for this account is suspended. Contact support@athanlab.com.
developer_not_approved403NoThis account has no approved developer application. Apply from the Developer API page in your dashboard.
account_suspended403NoThis account is suspended or scheduled for deletion. Contact support@athanlab.com.
scope_missing403NoThis API key does not have the scope this endpoint requires.
Billing billing_error
plan_required402NoCreating jobs through the API requires an active Max plan. Existing jobs, downloads and usage stay readable.
insufficient_characters402NoThe account does not have enough characters for this job. Nothing was charged. Top up Athan Tokens or wait for the monthly allowance to reset.
Rate limits rate_limit_error
rate_limited429YesToo many requests. Retry after the number of seconds in the Retry-After header.
auth_blocked429YesToo many failed authentication attempts from this network. Retry after the number of seconds in the Retry-After header.
concurrency_limit429YesThis account already has the maximum number of jobs running. Retry after one finishes (see Retry-After).
key_budget_exceeded429NoThis API key has reached its monthly character budget. Nothing was charged. The owner can raise the budget from the Developer API page.
Availability service_unavailable
api_disabled503YesThe AthanLab API is currently unavailable.
api_read_only503YesThe AthanLab API is in read-only mode: new jobs are paused, while existing jobs, downloads and usage stay available.
capacity_unavailable503YesThe speech service is at capacity. Nothing was charged. Retry after the number of seconds in the Retry-After header.
server_restarting503YesThe server is restarting. Nothing was charged. Retry in a few seconds.
server_busy503YesToo many downloads are in progress. Retry after the number of seconds in the Retry-After header.
Request invalid_request_error
validation_error400NoThe request is invalid.
invalid_cursor400NoThe pagination cursor is invalid. Use the next_cursor value from a previous list response, unchanged.
not_found404NoNo such endpoint or resource.
voice_not_found404NoNo voice with this id is available to this account.
idempotency_conflict409NoThis Idempotency-Key was already used with a different request. Use a new key for a new request.
job_not_finished409YesThe job has not finished yet. Poll GET /speech/{id} until its status is "succeeded".
job_failed409NoThe job failed or was cancelled, so it has no audio. See the job's error for details.
job_finished409NoThe job has already finished and can no longer be cancelled.
endpoint_removed410NoThis endpoint has been removed.
audio_expired410NoThe audio for this job is no longer available: it is deleted after the retention period.
payload_too_large413NoThe request body is too large.
unsupported_media_type415NoRequest bodies must be sent as Content-Type: application/json.
unspeakable_text422NoThe text has no speakable content. Send words to read aloud, not only emoji, punctuation or whitespace.
text_too_long422NoThe text is too long once numbers are written out as words. Send a shorter text or a different number_mode.
Server api_error
internal_error500YesSomething went wrong on our side. Retry the request; if it keeps failing, contact support@athanlab.com with the request_id.

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
LimitValue
Writes (POST: create, dry run, cancel)60 requests a minute per key (default)
Reads (GET)600 requests a minute per key
Per network600 requests a minute per client address, counting every request — signed with a key or not — across all your keys
Failed key checks20 in 5 minutes block the network for 10 minutes: 429 auth_blocked
Jobs running at once2 per account, up to 8 by arrangement. Beyond it: 429 concurrency_limit
Deliveries unfinished at once3 per account. Beyond it: 429 concurrency_limit
Downloads at once4 per account

Rate-limit headers

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
LimitValue
Text per job5,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 body128 KB (1 MB for POST /deliveries)
DeliveryUp 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.
Metadata16 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 sizeUp to 100 jobs (default 20)
Job deadline45 minutes. A job that cannot finish in time fails as capacity_unavailable and is refunded.
Audio and input text kept30 days (see Retention)
Sent to AthanInboxKept until you delete it (see AthanInbox)
API keys5 per account

Need more? Email support@athanlab.com with your use case.

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.

Retired endpoints and their replacements
EndpointReturnsUse insteadNote
POST /generate Synchronous generation410 endpoint_removedPOST /speechCreate an asynchronous job with POST /speech, poll it, then download the audio.
GET /balance Balance410 endpoint_removedGET /usageUse GET /usage.

The OpenAPI document lists them under x-removed-endpoints, not as operations.

Email support@athanlab.com with the request_id of the request in question and, for a job, its id. Never send your API key.

Reference generated from API 1.0.0 · API Terms version 2026-10-06