> Markdown version of https://athanlab.com/docs, the AthanLab Developer API documentation (API version 1.0.0). > > Each section is also served on its own at https://athanlab.com/docs/{section}.md, for example https://athanlab.com/docs/quickstart.md. Index of AthanLab pages for AI tools: https://athanlab.com/llms.txt # 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` > [!NOTE] > **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](https://athanlab.com/dashboard/api) of your dashboard. [Quickstart](https://athanlab.com/docs#quickstart) [Developer dashboard](https://athanlab.com/dashboard/api) [OpenAPI 3.1](https://api.athanlab.com/api/v1/openapi.json) ## Overview 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](https://athanlab.com/docs#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](https://athanlab.com/docs#athaninbox) with `POST /deliveries`. ### 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](https://athanlab.com/docs#authentication). | | Format | JSON over HTTPS | | Output | MP3 or WAV | | Text per job | Up to 5,000 characters. See [Rate limits and quotas](https://athanlab.com/docs#limits). | | Audio kept | 30 days | | AthanInbox | Sends audio to your AthanInbox on athanlab.com, where it stays until you delete it. See [AthanInbox](https://athanlab.com/docs#athaninbox). | | Specification | [OpenAPI 3.1](https://api.athanlab.com/api/v1/openapi.json). There is no official client library. Generate one from the OpenAPI document. | | Terms | [API Terms](https://athanlab.com/api-terms) · [Terms of Service](https://athanlab.com/terms) · [Acceptable Use Policy](https://athanlab.com/aup) | ## Get access **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](mailto:support@athanlab.com?subject=AthanLab%20Developer%20API) to ask about access. **1. Apply** On the [Developer API page](https://athanlab.com/dashboard/api) of your dashboard, give your company or project, what you are building and your expected monthly volume. Then accept the [API Terms](https://athanlab.com/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](https://athanlab.com/docs#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](https://athanlab.com/docs#authentication). > [!NOTE] > **When your Max plan ends** > > Creating a job or a delivery returns [402 plan_required](https://athanlab.com/docs#errors-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. ## Authentication Send an API key with every request except `GET /openapi.json`. Request headers: ```sh 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](https://athanlab.com/docs#scopes)). Shell: ```sh 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](https://athanlab.com/docs#errors-missing_api_key). An unknown, malformed, revoked or expired key returns [401 invalid_api_key](https://athanlab.com/docs#errors-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](https://athanlab.com/docs#errors-auth_blocked) (see [Rate limits](https://athanlab.com/docs#rate-limits)). > [!WARNING] > **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: | Scope | Endpoints | | --- | --- | | `speech:write` | [POST /speech](https://athanlab.com/docs#endpoint-post-speech), [POST /speech/{id}/cancel](https://athanlab.com/docs#endpoint-post-speech-id-cancel), [POST /deliveries](https://athanlab.com/docs#endpoint-post-deliveries) | | `speech:read` | [GET /speech](https://athanlab.com/docs#endpoint-get-speech), [GET /speech/{id}](https://athanlab.com/docs#endpoint-get-speech-id), [GET /speech/{id}/audio](https://athanlab.com/docs#endpoint-get-speech-id-audio), [GET /deliveries/{id}](https://athanlab.com/docs#endpoint-get-deliveries-id) | | `voices:read` | [GET /voices](https://athanlab.com/docs#endpoint-get-voices), [GET /voices/{id}/preview](https://athanlab.com/docs#endpoint-get-voices-id-preview) | | `usage:read` | [GET /usage](https://athanlab.com/docs#endpoint-get-usage) | A request outside a key's scopes returns [403 scope_missing](https://athanlab.com/docs#errors-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**. ## Quickstart Three requests: create a job, poll it, download the audio. > [!NOTE] > **Before you start** > > You need an API key with the `speech:write` and `speech:read` scopes (see [Get access](https://athanlab.com/docs#access)). Set it as `ATHANLAB_API_KEY` in the environment of a server-side process. **1. Create a job** Request: ```sh 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): ```json { "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](https://athanlab.com/docs#idempotency)). **2. Poll the job** Put the `id` from the response in `JOB_ID`, then fetch the job. Request: ```sh curl -sS https://api.athanlab.com/api/v1/speech/$JOB_ID \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK (example): ```json { "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](https://athanlab.com/docs#polling). **3. Download the audio** Request: ```sh 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: **curl** (terminal) ```sh # 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) ```python 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) ```js // 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](https://athanlab.com/docs#writing-text) first: sentence punctuation, made-up names, and how numbers are read. ### Next steps - [Writing text for speech](https://athanlab.com/docs#writing-text) - [Job lifecycle](https://athanlab.com/docs#jobs) - [Voices](https://athanlab.com/docs#voices) - [Errors](https://athanlab.com/docs#errors) - [AI agents (MCP)](https://athanlab.com/docs#agents) ## Writing text for speech 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](https://athanlab.com/docs#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](https://athanlab.com/docs#errors-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](https://athanlab.com/docs#errors-unspeakable_text). ### Numbers `number_mode` sets how digits are read. Here is each mode reading `ကျပ် ၁၂၃၄`: | 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](https://athanlab.com/docs#errors-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](https://athanlab.com/docs#limits)), 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)](https://athanlab.com/docs#agents)). ## Job lifecycle A job starts as `processing` and ends in exactly one final status. It never moves back. ### Statuses | Status | Meaning | Characters | | --- | --- | --- | | `processing` | Accepted and charged. `progress` rises from 0 toward 1. | Charged | | `succeeded` | The audio is ready: `audio.url` is the download path. | Charged | | `failed` | `error.code` says why (see [Job failure codes](https://athanlab.com/docs#job-failures)). | Refunded in full, automatically | | `cancelled` | You 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](https://athanlab.com/docs#rate-limits). 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: ```sh 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](https://athanlab.com/docs#errors-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](https://athanlab.com/docs#schema-DryRunQuote), as in this example: Request: ```sh 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: ```json { "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](https://athanlab.com/docs#errors-api_read_only) and after your Max plan ends. A finished job returns [409 job_finished](https://athanlab.com/docs#errors-job_finished). Request: ```sh 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](https://athanlab.com/docs#errors-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](https://athanlab.com/docs#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. > [!NOTE] > **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](https://athanlab.com/docs#errors-capacity_unavailable)). That 503 can reach you as an HTML 504 (see [Errors](https://athanlab.com/docs#errors)). | 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. | ## Voices `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](https://athanlab.com/aup)). Full details: [GET /voices](https://athanlab.com/docs#endpoint-get-voices) and [GET /voices/{id}/preview](https://athanlab.com/docs#endpoint-get-voices-id-preview) in the endpoint reference. ## Billing 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 | Event | Characters | | --- | --- | | Accepted job | The length of its text in UTF-16 code units (see [Size and retention](https://athanlab.com/docs#size-limits)), 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](https://athanlab.com/docs#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. | | Dry run, idempotent replay or rejected request | Nothing | | Numbers written out as words | Nothing extra (see [Numbers](https://athanlab.com/docs#number-modes)) | | Not enough characters | Nothing: creating the job or delivery returns [402 insufficient_characters](https://athanlab.com/docs#errors-insufficient_characters) | ### Per-key budgets You can give any key an optional monthly character budget on the [Developer API page](https://athanlab.com/dashboard/api). It is counted per UTC calendar month. Past it, creating a job or a delivery with that key returns [429 key_budget_exceeded](https://athanlab.com/docs#errors-key_budget_exceeded) and nothing is charged. For agents, see [A key for each agent](https://athanlab.com/docs#agent-keys). ### Paying for Max Pay with KBZPay rather than a card? Email [support@athanlab.com](mailto:support@athanlab.com?subject=AthanLab%20Developer%20API) 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](https://athanlab.com/docs#access) under Get access. ## Use with AI agents (MCP) `@athanlab/mcp` is an [MCP](https://modelcontextprotocol.io) 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](https://athanlab.com/docs#athaninbox), to listen to on athanlab.com. The server runs on your machine, calls the same endpoints as the [Quickstart](https://athanlab.com/docs#quickstart) and spends the same balance. ### Requirements - Node.js 20 or later on the machine that runs the agent. - An API key (see [Get access](https://athanlab.com/docs#access)), ideally [one made for the agent](https://athanlab.com/docs#agent-keys). ### Install **Claude Code** (terminal) ```sh 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`. **Codex** (~/.codex/config.toml) ```toml [mcp_servers.athanlab] command = "npx" args = ["-y", "@athanlab/mcp"] env_vars = ["ATHANLAB_API_KEY"] startup_timeout_sec = 30 tool_timeout_sec = 960 ``` - 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](https://athanlab.com/docs#mcp-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. **Codex desktop** (~/.codex/config.toml) ```toml [mcp_servers.athanlab] command = "npx" args = ["-y", "@athanlab/mcp"] startup_timeout_sec = 30 tool_timeout_sec = 960 [mcp_servers.athanlab.env] ATHANLAB_API_KEY = "ak_live_your_key_here" ATHANLAB_OUTPUT_DIR = "/Users/you/Documents/athanlab-audio" PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" ``` - 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//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. **Claude Desktop** (claude_desktop_config.json) ```json { "mcpServers": { "athanlab": { "command": "npx", "args": ["-y", "@athanlab/mcp"], "env": { "ATHANLAB_API_KEY": "ak_live_your_key_here", "ATHANLAB_OUTPUT_DIR": "/Users/you/Documents/athanlab-audio" } } } } ``` - Open Settings → Developer → Edit Config, then quit and restart the app. - Claude Desktop does not read your shell profile, so the key sits in this file in plain text. Keep the file private. - Give the output folder as an absolute path. **Cursor** (~/.cursor/mcp.json) ```json { "mcpServers": { "athanlab": { "type": "stdio", "command": "npx", "args": ["-y", "@athanlab/mcp"], "env": { "ATHANLAB_API_KEY": "${env:ATHANLAB_API_KEY}", "ATHANLAB_OUTPUT_DIR": "${userHome}/athanlab-audio" } } } } ``` - `${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](https://unpkg.com/@athanlab/mcp/skill/athanlab-voice/SKILL.md) 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. **Claude Code** (terminal) ```sh 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 ``` **Codex** (terminal) ```sh mkdir -p ~/.agents/skills/athanlab-voice curl -fsSL https://unpkg.com/@athanlab/mcp/skill/athanlab-voice/SKILL.md \ -o ~/.agents/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](https://athanlab.com/docs#limits). | 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](https://athanlab.com/docs#writing-text), for the agent to read before it writes text, or to rewrite a draft you give it. > [!WARNING] > **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](https://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/`, 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](https://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. > [!WARNING] > **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](https://athanlab.com/docs#agent-keys)). | 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](https://athanlab.com/docs#writing-text)). An account can have 3 deliveries unfinished at once. Send an `Idempotency-Key` with every `POST /deliveries` (see [Safe retries](https://athanlab.com/docs#idempotency)), and add `"dry_run": true` for a free [DeliveryQuote](https://athanlab.com/docs#schema-DeliveryQuote) (see [Quote first with dry_run](https://athanlab.com/docs#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: | 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](https://athanlab.com/docs#access)). Full details: [POST /deliveries](https://athanlab.com/docs#endpoint-post-deliveries) and [GET /deliveries/{id}](https://athanlab.com/docs#endpoint-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. | 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](https://athanlab.com/dashboard/api): - **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](https://athanlab.com/docs#errors-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](https://athanlab.com/docs#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](https://athanlab.com/docs#errors-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: ```json { "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 } ``` > [!WARNING] > **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): ```json { "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. > [!NOTE] > **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: ```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: ```sh 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. ## Endpoints 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](https://api.athanlab.com/api/v1/openapi.json). The response headers listed below leave out `X-Request-Id` and the `X-RateLimit-*` headers, which are covered in [Request IDs](https://athanlab.com/docs#request-ids) and [Rate limits and quotas](https://athanlab.com/docs#limits). ### Endpoints at a glance | Method | Path | Summary | Scope | | --- | --- | --- | --- | | POST | [/speech](https://athanlab.com/docs#endpoint-post-speech) | Create a speech job | `speech:write` | | GET | [/speech](https://athanlab.com/docs#endpoint-get-speech) | List speech jobs | `speech:read` | | GET | [/speech/{id}](https://athanlab.com/docs#endpoint-get-speech-id) | Get a speech job | `speech:read` | | POST | [/speech/{id}/cancel](https://athanlab.com/docs#endpoint-post-speech-id-cancel) | Cancel a speech job | `speech:write` | | GET | [/speech/{id}/audio](https://athanlab.com/docs#endpoint-get-speech-id-audio) | Download a job's audio | `speech:read` | | POST | [/deliveries](https://athanlab.com/docs#endpoint-post-deliveries) | Send to AthanInbox | `speech:write` | | GET | [/deliveries/{id}](https://athanlab.com/docs#endpoint-get-deliveries-id) | Get a delivery | `speech:read` | | GET | [/voices](https://athanlab.com/docs#endpoint-get-voices) | List voices | `voices:read` | | GET | [/voices/{id}/preview](https://athanlab.com/docs#endpoint-get-voices-id-preview) | Get a voice sample | `voices:read` | | GET | [/usage](https://athanlab.com/docs#endpoint-get-usage) | Get the account balance | `usage:read` | | GET | [/openapi.json](https://athanlab.com/docs#endpoint-get-openapi.json) | This OpenAPI document | No key needed | ### Common errors Every endpoint that takes a key can also return these errors: [401 missing_api_key](https://athanlab.com/docs#errors-missing_api_key) [401 invalid_api_key](https://athanlab.com/docs#errors-invalid_api_key) [403 developer_pending](https://athanlab.com/docs#errors-developer_pending) [403 developer_rejected](https://athanlab.com/docs#errors-developer_rejected) [403 developer_suspended](https://athanlab.com/docs#errors-developer_suspended) [403 developer_not_approved](https://athanlab.com/docs#errors-developer_not_approved) [403 account_suspended](https://athanlab.com/docs#errors-account_suspended) [403 scope_missing](https://athanlab.com/docs#errors-scope_missing) [429 rate_limited](https://athanlab.com/docs#errors-rate_limited) [429 auth_blocked](https://athanlab.com/docs#errors-auth_blocked) [500 internal_error](https://athanlab.com/docs#errors-internal_error) [503 api_disabled](https://athanlab.com/docs#errors-api_disabled) ### Speech Asynchronous speech jobs: create, poll, download, cancel. #### Create a speech job POST `https://api.athanlab.com/api/v1/speech` [Scope speech:write](https://athanlab.com/docs#scopes) [Max plan](https://athanlab.com/docs#access) [Paused in read-only mode](https://athanlab.com/docs#errors-api_read_only) Accepts and charges a job, then returns `202 Accepted` at once — the audio is generated in the background. 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](https://athanlab.com/docs#polling) · [Safe retries](https://athanlab.com/docs#idempotency) · [Quote first](https://athanlab.com/docs#dry-run) · [Writing text](https://athanlab.com/docs#writing-text) ##### Headers - `Idempotency-Key` (`string`, optional): 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. Constraints: 1–255 characters · pattern `^[\x20-\x7E]{1,255}$` ##### Body (JSON; unknown fields are rejected) - `text` (`string`, required): 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. Constraints: 1–5,000 characters - `voice_id` (`string | null`, optional): A voice from `GET /voices`. Defaults to the API default voice (`default_voice_id`). Constraints: 1–128 characters - `output_format` (`"mp3" | "wav"`, optional): The audio format the job prepares. Either format can still be downloaded later. Constraints: 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. Constraints: default `smart` - `metadata` (`map`, 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). Constraints: keys: 1–40 characters, pattern `^[A-Za-z0-9][A-Za-z0-9_.:-]*$` · values: at most 500 characters - `dry_run` (`boolean`, optional): Validate and quote the request without creating a job or charging anything. ##### Returns - 202 Accepted [CreateSpeechResponse](https://athanlab.com/docs#schema-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](https://athanlab.com/docs#schema-DryRunQuote)\ A quote for a `dry_run` request. Nothing was created or charged. ##### Errors [400 validation_error](https://athanlab.com/docs#errors-validation_error) [402 plan_required](https://athanlab.com/docs#errors-plan_required) [402 insufficient_characters](https://athanlab.com/docs#errors-insufficient_characters) [404 voice_not_found](https://athanlab.com/docs#errors-voice_not_found) [409 idempotency_conflict](https://athanlab.com/docs#errors-idempotency_conflict) [413 payload_too_large](https://athanlab.com/docs#errors-payload_too_large) [415 unsupported_media_type](https://athanlab.com/docs#errors-unsupported_media_type) [422 unspeakable_text](https://athanlab.com/docs#errors-unspeakable_text) [422 text_too_long](https://athanlab.com/docs#errors-text_too_long) [429 concurrency_limit](https://athanlab.com/docs#errors-concurrency_limit) [429 key_budget_exceeded](https://athanlab.com/docs#errors-key_budget_exceeded) [503 api_read_only](https://athanlab.com/docs#errors-api_read_only) [503 capacity_unavailable](https://athanlab.com/docs#errors-capacity_unavailable) [503 server_restarting](https://athanlab.com/docs#errors-server_restarting) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh 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: ```json { "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: ```json { "object": "speech.quote", "characters": 35, "dispatches": 1, "spendable": 1950000, "sufficient": true } ``` #### List speech jobs GET `https://api.athanlab.com/api/v1/speech` [Scope speech:read](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) 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` (`integer`, optional): Jobs per page, 1–100. Constraints: 1–100 · default `20` - `cursor` (`string`, optional): The `next_cursor` of the previous page. ##### Returns - 200 OK [SpeechList](https://athanlab.com/docs#schema-SpeechList)\ A page of jobs. ##### Errors [400 validation_error](https://athanlab.com/docs#errors-validation_error) [400 invalid_cursor](https://athanlab.com/docs#errors-invalid_cursor) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh curl -sS https://api.athanlab.com/api/v1/speech \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK: ```json { "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}` [Scope speech:read](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) The job's current state. Poll it while `status` is `processing`, as https://athanlab.com/docs#polling describes. A job of another account returns 404. See: [Polling](https://athanlab.com/docs#polling) ##### Path parameters - `id` (`string`, required): The job id from the create response. Constraints: pattern `^[0-9a-f]{32}$` ##### Returns - 200 OK [SpeechJob](https://athanlab.com/docs#schema-SpeechJob)\ The job. ##### Errors [404 not_found](https://athanlab.com/docs#errors-not_found) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh curl -sS https://api.athanlab.com/api/v1/speech/$JOB_ID \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK: ```json { "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` [Scope speech:write](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) Stops a job that is still processing. The share already generated stays charged; the rest is refunded (`usage.characters_refunded`). 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](https://athanlab.com/docs#cancel) ##### Path parameters - `id` (`string`, required): The job id from the create response. Constraints: pattern `^[0-9a-f]{32}$` ##### Returns - 200 OK [SpeechJob](https://athanlab.com/docs#schema-SpeechJob)\ The cancelled job. ##### Errors [404 not_found](https://athanlab.com/docs#errors-not_found) [409 job_finished](https://athanlab.com/docs#errors-job_finished) [413 payload_too_large](https://athanlab.com/docs#errors-payload_too_large) [415 unsupported_media_type](https://athanlab.com/docs#errors-unsupported_media_type) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh curl -sS -X POST https://api.athanlab.com/api/v1/speech/$JOB_ID/cancel \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK: ```json { "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` [Scope speech:read](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) 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). `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](https://athanlab.com/docs#retention) ##### Path parameters - `id` (`string`, required): The job id from the create response. Constraints: 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` ##### Errors [400 validation_error](https://athanlab.com/docs#errors-validation_error) [404 not_found](https://athanlab.com/docs#errors-not_found) [409 job_not_finished](https://athanlab.com/docs#errors-job_not_finished) [409 job_failed](https://athanlab.com/docs#errors-job_failed) [410 audio_expired](https://athanlab.com/docs#errors-audio_expired) [503 server_busy](https://athanlab.com/docs#errors-server_busy) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh 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` [Scope speech:write](https://athanlab.com/docs#scopes) [Max plan](https://athanlab.com/docs#access) [Paused in read-only mode](https://athanlab.com/docs#errors-api_read_only) Sends audio to AthanInbox, the inbox on athanlab.com of the account that owns the key — never anyone else's. 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](https://athanlab.com/docs#athaninbox) · [Safe retries](https://athanlab.com/docs#idempotency) · [Writing text](https://athanlab.com/docs#writing-text) ##### Headers - `Idempotency-Key` (`string`, optional): 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. Constraints: 1–255 characters · pattern `^[\x20-\x7E]{1,255}$` ##### Body (JSON; unknown fields are rejected) - `title` (`string`, required): What the listener sees in AthanInbox, 1–120 characters. Leading and trailing spaces are removed; line breaks and other control characters are refused. Constraints: 1–120 characters - `text` (`string`, optional): The text of a one-chapter delivery, 1–5,000 characters. Send exactly one of `text` and `chapters`. Constraints: 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 | null`, optional): A voice from `GET /voices`, used for every chapter. Defaults to the API default voice (`default_voice_id`). Constraints: 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. Constraints: 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. Constraints: 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`, 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). Constraints: keys: 1–40 characters, pattern `^[A-Za-z0-9][A-Za-z0-9_.:-]*$` · values: at most 500 characters - `dry_run` (`boolean`, optional): Validate and quote the delivery without creating it or charging anything. ##### Returns - 202 Accepted [CreateDeliveryResponse](https://athanlab.com/docs#schema-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](https://athanlab.com/docs#schema-DeliveryQuote)\ A quote for a `dry_run` request. Nothing was created or charged. ##### Errors [400 validation_error](https://athanlab.com/docs#errors-validation_error) [402 plan_required](https://athanlab.com/docs#errors-plan_required) [402 insufficient_characters](https://athanlab.com/docs#errors-insufficient_characters) [404 not_found](https://athanlab.com/docs#errors-not_found) [404 voice_not_found](https://athanlab.com/docs#errors-voice_not_found) [409 idempotency_conflict](https://athanlab.com/docs#errors-idempotency_conflict) [413 payload_too_large](https://athanlab.com/docs#errors-payload_too_large) [415 unsupported_media_type](https://athanlab.com/docs#errors-unsupported_media_type) [422 unspeakable_text](https://athanlab.com/docs#errors-unspeakable_text) [422 text_too_long](https://athanlab.com/docs#errors-text_too_long) [429 concurrency_limit](https://athanlab.com/docs#errors-concurrency_limit) [429 key_budget_exceeded](https://athanlab.com/docs#errors-key_budget_exceeded) [503 api_read_only](https://athanlab.com/docs#errors-api_read_only) [503 server_restarting](https://athanlab.com/docs#errors-server_restarting) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh 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" }' ``` 202 Accepted · CreateDeliveryResponse: ```json { "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: ```json { "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}` [Scope speech:read](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) 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. 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](https://athanlab.com/docs#athaninbox) ##### Path parameters - `id` (`string`, required): The delivery id from the create response. Constraints: pattern `^[0-9a-f]{32}$` ##### Returns - 200 OK [Delivery](https://athanlab.com/docs#schema-Delivery)\ The delivery. ##### Errors [404 not_found](https://athanlab.com/docs#errors-not_found) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh curl -sS https://api.athanlab.com/api/v1/deliveries/$DELIVERY_ID \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK: ```json { "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` [Scope voices:read](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) AthanLab's official voices, then the voices in your own library. `default_voice_id` is the voice a job uses when it names none. Voices are added to your library in the AthanLab app, under the same consent rules as every other voice. See: [Voices](https://athanlab.com/docs#voices) ##### Returns - 200 OK [VoiceList](https://athanlab.com/docs#schema-VoiceList)\ The voices this account can use. ##### Example Request: ```sh curl -sS https://api.athanlab.com/api/v1/voices \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK: ```json { "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` [Scope voices:read](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) A short-lived signed URL of the voice's reference sample, to let people hear a voice before choosing it. See: [Voices](https://athanlab.com/docs#voices) ##### Path parameters - `id` (`string`, required): A voice id from `GET /voices`. Constraints: 1–128 characters ##### Returns - 200 OK [VoicePreview](https://athanlab.com/docs#schema-VoicePreview)\ The sample URL. ##### Errors [404 voice_not_found](https://athanlab.com/docs#errors-voice_not_found) Plus the [common errors](https://athanlab.com/docs#common-errors). ##### Example Request: ```sh curl -sS https://api.athanlab.com/api/v1/voices/$VOICE_ID/preview \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK: ```json { "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` [Scope usage:read](https://athanlab.com/docs#scopes) [Works in read-only mode](https://athanlab.com/docs#errors-api_read_only) 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. 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](https://athanlab.com/docs#billing) ##### Returns - 200 OK [Usage](https://athanlab.com/docs#schema-Usage)\ The balance. ##### Example Request: ```sh curl -sS https://api.athanlab.com/api/v1/usage \ -H "X-API-Key: $ATHANLAB_API_KEY" ``` 200 OK: ```json { "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` ##### Errors [429 rate_limited](https://athanlab.com/docs#errors-rate_limited) [500 internal_error](https://athanlab.com/docs#errors-internal_error) ##### Example Request: ```sh 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](https://athanlab.com/docs#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. Constraints: 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). Constraints: 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. Constraints: at least 0 - `usage.characters_refunded` (`integer`): Characters returned since: all of them when the job failed, the unprocessed share when it was cancelled. Constraints: at least 0 - `audio` ([SpeechAudio](https://athanlab.com/docs#schema-SpeechAudio) | null): Present while the job has downloadable audio: succeeded and inside the retention period. Null otherwise. - `error` ([SpeechJobError](https://athanlab.com/docs#schema-SpeechJobError) | null): Why the job failed; null unless status is `failed`. - `metadata` (`map`): 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-` — `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. Constraints: 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](https://athanlab.com/docs#schema-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. Constraints: at least 0 #### SpeechList - `object` (`"list"`): Always `list`. - `data` ([SpeechJob\[\]](https://athanlab.com/docs#schema-SpeechJob)): 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). Constraints: greater than 0 - `dispatches` (`integer`): How many pieces the text is split into for synthesis. Constraints: greater than 0 - `spendable` (`integer`): Monthly allowance remaining plus Athan Tokens, right now. Constraints: 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. Constraints: 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). Constraints: greater than 0 - `input.chapters` (`integer`): How many chapters were sent. Constraints: 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` ([DeliveryChapter\[\]](https://athanlab.com/docs#schema-DeliveryChapter)): The chapters, in play order; empty once the delivery is deleted. - `sources` ([DeliverySource\[\]](https://athanlab.com/docs#schema-DeliverySource)): 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. Constraints: 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). Constraints: 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`): 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. Constraints: at least 0 - `title` (`string | null`): The chapter's title as sent; null when none was sent. - `characters` (`integer`): Characters this chapter was charged. Constraints: 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. Constraints: at least 0 - `error` ([DeliveryChapterError](https://athanlab.com/docs#schema-DeliveryChapterError) | null): 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](https://athanlab.com/docs#schema-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. Constraints: 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). Constraints: greater than 0 - `chapters` (`integer`): How many chapters the delivery has. Constraints: greater than 0 - `dispatches` (`integer`): How many pieces the chapters are split into for synthesis, in total. Constraints: greater than 0 - `spendable` (`integer`): Monthly allowance remaining plus Athan Tokens, right now. Constraints: 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` ([Voice\[\]](https://athanlab.com/docs#schema-Voice)): 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. Constraints: 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. Constraints: at least 0 - `monthly.used` (`integer`): Characters used this period. Constraints: at least 0 - `monthly.remaining` (`integer`): Characters left this period. Constraints: 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. Constraints: at least 0 - `spendable` (`integer`): monthly.remaining + tokens.balance. Constraints: at least 0 - `active_jobs` (`integer`): The account's jobs processing right now, across all its keys. Constraints: 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`. Constraints: 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. Constraints: 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. Constraints: 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`. Constraints: 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. ## Errors 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: ```json { "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 } } } ``` > [!NOTE] > **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. Constraints: pattern `^req_[0-9a-f]{24}$` - `error.doc_url` (`string`): This error's entry in the documentation. - `error.details` (`object`, may 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` (`string`, may 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](https://athanlab.com/docs#support). ### Error codes The codes are grouped by `error.type`. For a code not listed here, see [Versioning](https://athanlab.com/docs#versioning). **Authentication** (`authentication_error`) | Code | HTTP | Retryable | Meaning | | --- | --- | --- | --- | | missing_api_key | 401 | No | No API key provided. Send it in the X-API-Key header or as Authorization: Bearer \. | | invalid_api_key | 401 | No | The API key is invalid, revoked or expired. | **Access** (`permission_error`) | Code | HTTP | Retryable | Meaning | | --- | --- | --- | --- | | developer_pending | 403 | No | Your developer application is still under review. Keys start working once it is approved. | | developer_rejected | 403 | No | Your developer application was not approved. See the Developer API page in your dashboard. | | developer_suspended | 403 | No | Developer API access for this account is suspended. Contact support@athanlab.com. | | developer_not_approved | 403 | No | This account has no approved developer application. Apply from the Developer API page in your dashboard. | | account_suspended | 403 | No | This account is suspended or scheduled for deletion. Contact support@athanlab.com. | | scope_missing | 403 | No | This API key does not have the scope this endpoint requires. | **Billing** (`billing_error`) | Code | HTTP | Retryable | Meaning | | --- | --- | --- | --- | | plan_required | 402 | No | Creating jobs through the API requires an active Max plan. Existing jobs, downloads and usage stay readable. | | insufficient_characters | 402 | No | The 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`) | Code | HTTP | Retryable | Meaning | | --- | --- | --- | --- | | rate_limited | 429 | Yes | Too many requests. Retry after the number of seconds in the Retry-After header. | | auth_blocked | 429 | Yes | Too many failed authentication attempts from this network. Retry after the number of seconds in the Retry-After header. | | concurrency_limit | 429 | Yes | This account already has the maximum number of jobs running. Retry after one finishes (see Retry-After). | | key_budget_exceeded | 429 | No | This 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`) | Code | HTTP | Retryable | Meaning | | --- | --- | --- | --- | | api_disabled | 503 | Yes | The AthanLab API is currently unavailable. | | api_read_only | 503 | Yes | The AthanLab API is in read-only mode: new jobs are paused, while existing jobs, downloads and usage stay available. | | capacity_unavailable | 503 | Yes | The speech service is at capacity. Nothing was charged. Retry after the number of seconds in the Retry-After header. | | server_restarting | 503 | Yes | The server is restarting. Nothing was charged. Retry in a few seconds. | | server_busy | 503 | Yes | Too many downloads are in progress. Retry after the number of seconds in the Retry-After header. | **Request** (`invalid_request_error`) | Code | HTTP | Retryable | Meaning | | --- | --- | --- | --- | | validation_error | 400 | No | The request is invalid. | | invalid_cursor | 400 | No | The pagination cursor is invalid. Use the next_cursor value from a previous list response, unchanged. | | not_found | 404 | No | No such endpoint or resource. | | voice_not_found | 404 | No | No voice with this id is available to this account. | | idempotency_conflict | 409 | No | This Idempotency-Key was already used with a different request. Use a new key for a new request. | | job_not_finished | 409 | Yes | The job has not finished yet. Poll GET /speech/{id} until its status is "succeeded". | | job_failed | 409 | No | The job failed or was cancelled, so it has no audio. See the job's error for details. | | job_finished | 409 | No | The job has already finished and can no longer be cancelled. | | endpoint_removed | 410 | No | This endpoint has been removed. | | audio_expired | 410 | No | The audio for this job is no longer available: it is deleted after the retention period. | | payload_too_large | 413 | No | The request body is too large. | | unsupported_media_type | 415 | No | Request bodies must be sent as Content-Type: application/json. | | unspeakable_text | 422 | No | The text has no speakable content. Send words to read aloud, not only emoji, punctuation or whitespace. | | text_too_long | 422 | No | The text is too long once numbers are written out as words. Send a shorter text or a different number_mode. | **Server** (`api_error`) | Code | HTTP | Retryable | Meaning | | --- | --- | --- | --- | | internal_error | 500 | Yes | Something went wrong on our side. Retry the request; if it keeps failing, contact support@athanlab.com with the request_id. | ## Rate limits and quotas 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 | 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](https://athanlab.com/docs#errors-auth_blocked) | | Jobs running at once | 2 per account, up to 8 by arrangement. Beyond it: [429 concurrency_limit](https://athanlab.com/docs#errors-concurrency_limit) | | Deliveries unfinished at once | 3 per account. Beyond it: [429 concurrency_limit](https://athanlab.com/docs#errors-concurrency_limit) | | Downloads at once | 4 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. > [!NOTE] > **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](https://athanlab.com/docs#errors-key_budget_exceeded) is not retryable: the key has spent its monthly budget (see [Per-key budgets](https://athanlab.com/docs#per-key-budgets)). ### Size and retention | 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](https://athanlab.com/docs#job-failures) and is refunded. | | Audio and input text kept | 30 days (see [Retention](https://athanlab.com/docs#retention)) | | Sent to AthanInbox | Kept until you delete it (see [AthanInbox](https://athanlab.com/docs#athaninbox)) | | API keys | 5 per account | Need more? Email [support@athanlab.com](mailto:support@athanlab.com?subject=AthanLab%20Developer%20API) with your use case. ## Versioning and changelog 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](https://athanlab.com/docs#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](https://athanlab.com/docs#errors-endpoint_removed). Sign-in-token access has been removed too: send an API key. Any other credential returns [401 invalid_api_key](https://athanlab.com/docs#errors-invalid_api_key) and counts toward the per-network limit on failed key checks. | Endpoint | Returns | Use instead | Note | | --- | --- | --- | --- | | POST /generate Synchronous generation | [410 endpoint_removed](https://athanlab.com/docs#errors-endpoint_removed) | [POST /speech](https://athanlab.com/docs#endpoint-post-speech) | Create an asynchronous job with `POST /speech`, poll it, then download the audio. | | GET /balance Balance | [410 endpoint_removed](https://athanlab.com/docs#errors-endpoint_removed) | [GET /usage](https://athanlab.com/docs#endpoint-get-usage) | Use `GET /usage`. | The OpenAPI document lists them under `x-removed-endpoints`, not as operations. ## Support Email [support@athanlab.com](mailto:support@athanlab.com?subject=AthanLab%20Developer%20API) 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