YarnGPT API
One API for African voice — TTS and STT today, dubbing soon. Built for developers.
First time here? Start with the QuickstartA guided, 5-minute path from API key to your first synthesized audio.Authentication
Developer routes under /api/v1/* are authenticated with a Bearer API key. To get an API key, sign up with Google or an email and password, verify your email, then create one from your Account page. Key management itself needs a signed-in browser session and has no API route.
Authorization: Bearer YOUR_API_KEYThe scheme name isn't case-sensitive (Bearer, bearer both work), but nothing else authenticates — no query parameter, no cookie. There's exactly one exception: GET /api/v1/tts/stream/{ticket} takes no key at all, since the ticket in its path is itself the credential.
A key is shown in full only once, right after creation — copy it then, or revoke it and make a new one. There's no sandbox and no test key: every call, on every key, spends real credits, so try things out with short text and recordings. Switching a key off is reversible; revoking one is permanent. Keys, credits, and rate limits are all scoped to your account, not the individual key — holding several keys just lets you revoke one service's credential without disturbing the rest, not buy extra allowance.
Every problem with a key — missing, malformed, revoked, switched off, or an account that's suspended or closed — answers identically: 401 INVALID_API_KEY. That's deliberate, so a failed request can't be used to probe whether a key or account exists.
Text-to-Speech
Queues an asynchronous job and returns a job_id — poll GET /api/v1/status/{job_id} until it's completed and read audio_url. Requires an Idempotency-Key header; replaying the same key returns the original job instead of billing you twice.
job_id (alongside the Idempotency-Key that made it) the moment you get it back, or it's lost.Endpoint
POST https://api.yarngpt.ai/api/v1/ttsRequest body
| Parameter | Type | Required | Description |
|---|---|---|---|
| text | string | Yes | Text to convert. Max 15,000 characters. |
| voice | string | No | A voice id from GET /api/v1/voices — exact, case-sensitive. The set of ids isn't fixed and changes over time; don't hardcode one. An unknown id is 404 VOICE_NOT_FOUND (also returned by GET /api/v1/voices/{name} for a name that doesn't exist). Omit to use the provider's own default (the catalogue entry with default: true). |
| output_format | string | No | mp3 (default) or wav — this job route only supports those two. pcm, opus, and flac are available on tts/prepare's stream instead (see below). Anything else is 422 INVALID_INPUT. |
| target_language | string | No | Translate the text into one of 18 codes before synthesising it: am ar de en es fr ha ig lg pt rw sn sw tw wo xh yo zu. Omit for the text as written. A code outside the list is 422 INVALID_LANGUAGE_PAIR with details.reason unsupported_language (and details.supported lists every code we do translate into — read that instead of hardcoding the list above); the same error with reason translation_not_available means this deployment has no translation configured at all. On success the response gains target_language and a warnings array — for example code VOICE_LANGUAGE_UNLISTED when the chosen voice doesn't list that language — show warnings[].message as advice, not an error. Billed on the translated text, which can cost more or less than the original. |
Past a certain length the server can also refuse with 400 TEXT_TOO_LONG even though your text is under the 15,000-character cap — the text is split into segments before it's spoken, and one request may carry at most 60. Plain prose rarely gets close (15,000 characters of ordinary prose made 48 segments), but text packed with prices, numbers, or symbols can run over, since each one is read out in full. Read details.chunk_cap and details.chunks from the error rather than hardcoding a number, and split the text into as few parts as will pass rather than retrying as-is.
202 Accepted comes back with job_id, status: "queued", accepted_at, target_language, and warnings. Sending the same Idempotency-Key and the same body again returns the original job (same job_id and accepted_at) and charges nothing more; the same key with a different body is 409 IDEMPOTENCY_KEY_REUSED, and the same key while the first request is still running is 409 ALREADY_EXISTS — wait and retry with the same key rather than a new one. A key stays tied to its job for as long as the job exists — there is no expiry window to retry within — but a refusal releases the key, so you can fix the request and resend it under the same key. And after a 500/502/503/504, the same key is always safe to retry with the identical body — never with a changed one, which is the one case the server can't tell apart from a new request.
Three 402s mean different things: QUOTA_EXCEEDED (details.credits_remaining/credits_required) is a balance problem — top up. SUBSCRIPTION_INACTIVE (details.subscription_status) means the subscription itself isn't in good standing — buying credits won't fix it; settle it on the Account page instead. SPENDING_LIMIT_REACHED only fires for a key made under an organisation workspace, when a tenant admin's own cap on that key or its member (details.limit_subject: member or key) is used up — neither a top-up nor the Account page changes it; only a tenant admin raising the limit does.
Poll for the result
GET https://api.yarngpt.ai/api/v1/status/{job_id}percentage behaves differently here than on a transcription job: it climbs — 0 while queued, 10 once synthesis starts, rising as each part finishes, 100 when done — so it's safe to drive an actual progress bar off it, unlike ASR's all-or-nothing version. audio_url is a freshly signed link on every poll (about an hour to live); error_code is always null on this route (it's only ever set on a transcription job) — show user_message instead when status is failed. An unknown job_id, a malformed one, and another account's job all answer the same deliberate way: 404 JOB_NOT_FOUND.
Mint a streaming ticket instead
POST /api/v1/tts/prepare takes the same body as above but supports all five output formats, doesn't read an Idempotency-Key at all, and doesn't check your balance (unless target_language is set, which is billed immediately) — a ticket can be minted with a zero balance, and the refusal only shows up when the stream is fetched. The response is {ticket, stream_url, expires_in, target_language, warnings}; stream_url is a path only — prepend the base URL yourself.
GET /api/v1/tts/stream/{ticket} sends no key — the ticket in the path is the credential. While the audio is being made it answers 200 with the bytes streaming live; once it's already been made and stored, the same URL instead answers 303 See Other with an empty body and a signed link in Location — follow redirects (curl -L) or you'll save an empty file. Repeated fetches of one ticket are billed once in total, however many times you fetch it; if the stored audio is incomplete, the redirect also carries X-Yarn-Partial: 1. If the account behind the ticket closes or is suspended after minting, the fetch is 410, not 404. A browser's <audio> element shows none of this — a refusal just looks like a failed load — so check the balance server-side before handing a ticket to a page.
Conversation (real-time single-turn)
For voice agents and live chat replies. Returns raw audio directly in the response — no job_id, no polling. Text is capped shorter than the job routes — 1–5,000 characters instead of 15,000, still at most 60 segments.
Idempotency-Key is optional here, but worth sending, since the request streams audio back in the same call — a dropped connection otherwise leaves you unsure whether you were charged. With a key: the first call is charged, and the same key with the same body can re-stream the same audio again free for a short window afterwards — past that window, or too many replays within it, the same key is refused (429 RATE_LIMITED or 409 IDEMPOTENCY_KEY_REUSED) and you start again with a new key. A changed body under the same key is always 409 IDEMPOTENCY_KEY_REUSED. Without a key, a retry is a second charge.
Endpoint
POST https://api.yarngpt.ai/api/v1/streaming/conversationBody: text, voice, output_format — pcm, wav, or mp3. Pick pcm (raw signed 16-bit little-endian → audio/l16) to pipe straight into a telephony/audio pipeline with no decode step. Also accepts an optional target_language (see the Text-to-Speech section above for the 18 codes) — since the body here is audio, not JSON, any warning comes back as a response header instead: X-Synthesis-Warning, comma-separated for more than one, exposed for cross-origin JavaScript to read.
curl -N -X POST "https://api.yarngpt.ai/api/v1/streaming/conversation" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: a-fresh-key-per-turn" \ -H "Content-Type: application/json" \ -d '{ "text": "Hi there, how can I help?", "voice": "umar", "output_format": "pcm" }' \ --output reply.pcm
-N disables buffering so you receive audio as it streams. Besides its daily cap (see Rate limits below), this route also caps how many streams your account can have open at once — going over is 429 RATE_LIMITED with no Retry-After; a slot frees when its response ends, including on hang-up, or automatically after a timeout if a slot is lost to a crash.
Everything that can refuse the request is checked before the first byte is sent — a 200 means audio is genuinely on its way, not a placeholder that might still fail. Alongside the audio itself, the response carries Content-Disposition: inline; filename=agent_response.<format> and Cache-Control: no-cache.
Content-Type: audio/l16; rate=<N>; channels=1 for pcm — and hand that number to your decoder instead of assuming a constant. A client that hardcodes one plays back at the wrong speed and pitch with no error anywhere. wav/mp3 carry their rate in the container, so they decode fine either way. This route can also refuse with 400 TEXT_TOO_LONG (see the Text-to-Speech section above for what that means and how to handle it).Speech-to-Text
The same asynchronous job pattern as Text-to-Speech, in reverse: upload an audio file, get a job_id back immediately along with a price quote, then poll until the transcript is ready. Requires an Idempotency-Key header, same as POST /api/v1/tts. There's no field to choose or hint the spoken language — GET /api/v1/asr/languages returns the languages transcription covers (an opaque model_id plus a languages array, read it rather than hardcoding it — a 503 PROVIDER_TIMEOUT here is common while the provider is idle and just worth a quick retry, not a real outage), not a control. Audio in an unlisted language still gets transcribed — badly — and is still charged; there's no confidence score or language-detection warning to catch it.
Upload
POST https://api.yarngpt.ai/api/v1/asrUnlike every other route on this page, the body is multipart/form-data, not JSON — one part, file.
| Parameter | Type | Required | Description |
|---|---|---|---|
| file | file | Yes | Checked by the file's actual contents, not its name or Content-Type — renaming a .txt to .mp3 won't get past it. MP3, WAV, M4A, MP4, OGG, FLAC, and WebM containers are accepted — audio-only WebM (what browser recording produces) works, but a file carrying a video track is refused, this route takes audio. Up to 64 MiB and 600 seconds; a file over the gateway's own body limit never reaches the API at all and is rejected earlier, at the edge, with the same 413 PAYLOAD_TOO_LARGE code but service: "nginx-ingress". |
| target_language | string | No | Translate the transcript after transcribing it — same 18 codes as POST /api/v1/tts's target_language, sent as a form field here instead of JSON. No extra charge either way. A failed translation doesn't fail the job — the poll response holds the original transcript plus a translation_status of failed and a translation_user_message explaining why. |
| Idempotency-Key (header) | string | Yes | 1 to 64 characters, no colon, generated fresh per upload, not per session. Replaying the same key and file returns the original job, not a new transcription — the key stays tied to that job for as long as it exists, not just for a few hours. Keep the same key only for retrying a dropped connection; a changed file under a used key is always 409 IDEMPOTENCY_KEY_REUSED. |
curl -X POST "https://api.yarngpt.ai/api/v1/asr" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: a-fresh-key-per-upload" \ -F "file=@meeting.mp3" # → 202 {"job_id":"...","status":"queued","accepted_at":"...", # "audio_seconds":12.042,"billable_blocks":1,"credits_reserved":1,"target_language":null}
The three numbers in the response are a quote, read from the file's container header, not a bill — what you're actually charged settles once transcription finishes. Audio is billed in whole blocks, so a short clip can cost the same as a longer one within the same block; read billable_blocks rather than assuming a block size, and treat any price you show before upload as an estimate.
Poll for the result
GET https://api.yarngpt.ai/api/v1/asr/{job_id}status is queued or processing while it works, completed or failed when it's done — branch on those two, not on whether transcript is non-null, since a completed job can legitimately have an empty transcript when the audio is silence. percentage isn't a progress bar: it's 0 while running and 100 when done, with nothing reported in between — drive your UI off status and show an indeterminate spinner. Poll on an interval, not in a loop — start around one second and back off toward five; a tight retry loop with no delay hits the gateway's own burst limit rather than anything application-level.
credits_charged is null until the job settles, and it's a separate figure from billable_blocks — they diverge whenever your plan's rate multiplier isn't 1.0. Show credits_reserved from the upload response as a hold, then replace it with credits_charged once it arrives. A failed job costs nothing — its reservation is refunded, not spent.
When status is failed, show error_message directly — it's written to be read — and offer a retry only when audio_available is true: some failure paths delete the uploaded audio as part of cleanup, leaving nothing to retry from. error_code is filled in only here (never on a TTS job) and is one of PROVIDER_ERROR, PROVIDER_TIMEOUT, PROVIDER_REJECTED, INVALID_INPUT, INTERNAL_ERROR, ALREADY_DELETED, or SERVICE_UNAVAILABLE.
If you sent target_language, the poll response also carries target_language, translation_status (null until a target was asked for and the job itself succeeded, pending while translating, then completed or failed), translated_transcript (set only once completed — an empty string is a valid translation), and translated_chars. A job with a target can stay in processing a little longer, since translation runs after transcription — keep polling.
Fetch the uploaded audio
GET https://api.yarngpt.ai/api/v1/asr/{job_id}/audioReturns {url, expires_at, audio_expires_at} — a JSON body, never a redirect. Two different clocks: expires_at is when the signed url itself stops working (about an hour); audio_expires_at is when the underlying file may be deleted (12 months after upload). A link can go stale long before the audio is gone — fetch a fresh one each time rather than storing it, and only when the user actually asks to play or download, never on page load. A link minted just before the file is deleted can outlive it, so handle a failed download by polling the job again, not by re-fetching the same link. The transcript is kept even after the audio expires; a request after that point returns 410, not 404.
Pricing
Audio is priced separately from text, in whole billable blocks rather than a flat per-second rate. There's no route that reports the current rate or your balance; both are shown on the Account page — read them there rather than hardcoding a number, since pricing can change. The numbers on the upload's 202 (audio_seconds, billable_blocks, credits_reserved) are a quote from the container header, not the bill — the charge is settled from the audio actually decoded and appears as credits_charged once the job is done.
404 JOB_NOT_FOUND on the status and audio-link routes, deliberately, so a caller can't use the response to probe whether a job id exists. Once the uploaded recording is deleted (12 months after upload), its audio link answers 410 ALREADY_DELETED instead — the transcript itself is kept and stays readable.Live transcription (streaming)
One route, the opposite trade-off from the upload flow above: open a WebSocket, send audio as it's captured, and get text back segment by segment instead of waiting for a job to finish. Use it for a live caption or a voice-driven feature — for turning an existing recording into text, the upload route is both simpler and faster, since a stream has to be fed at the speed the audio plays.
WS https://api.yarngpt.ai/api/v1/asr/streamBefore you start
Call this from a server, not from a web page. Your key travels in the Authorization header of the WebSocket handshake, and a browser cannot set a header on a WebSocket it opens — there's no way to use this route from client-side JavaScript at all. A key placed in a page is also readable by anyone who opens it.
| What | Where it comes from |
|---|---|
| Base URL | Given with your account. Replace https:// with wss:// for the socket. |
| API key | Same sk_live_... key as every other route on this page. |
| Credits | Every stream is paid for from your balance, shown on the Account page. |
Quick start
Both stream a file and print each segment as it's transcribed. The audio must already be raw 16-bit PCM, mono, 8,000–48,000 Hz — ffmpeg -i speech.mp3 -f s16le -ac 1 -ar 16000 speech.pcm gets there from almost anything.
# pip install websockets (version 14 or later)
import asyncio, json, os, sys, wave, websockets
BASE_URL = os.environ["YARNGPT_BASE_URL"]
API_KEY = os.environ["YARNGPT_API_KEY"]
STREAM_URL = BASE_URL.replace("https://", "wss://") + "/api/v1/asr/stream"
CHUNK_SECONDS = 0.25
async def send_audio(ws, wav):
frames_per_chunk = int(wav.getframerate() * CHUNK_SECONDS)
while chunk := wav.readframes(frames_per_chunk):
await ws.send(chunk) # bytes: sent as a binary message
await asyncio.sleep(CHUNK_SECONDS) # send at the speed the audio plays
await ws.send(json.dumps({"type": "finalize"}))
async def main(path):
wav = wave.open(path, "rb")
ws = await websockets.connect(
STREAM_URL,
additional_headers={"Authorization": f"Bearer {API_KEY}"},
)
async with ws:
await ws.send(json.dumps({
"type": "start",
"audio": {"encoding": "pcm_s16le", "sample_rate": wav.getframerate(), "channels": 1},
}))
sender = None
async for raw in ws:
msg = json.loads(raw)
if msg["type"] == "started":
sender = asyncio.create_task(send_audio(ws, wav))
elif msg["type"] == "final":
print(f"[{msg['start_seconds']:.1f}-{msg['end_seconds']:.1f}s]", msg["text"])
elif msg["type"] == "done":
print("done:", msg["reason"], "| job", msg["job_id"])
elif msg["type"] == "error":
print("error:", msg["error"]["code"], msg["error"]["message"])
if sender: sender.cancel()
asyncio.run(main(sys.argv[1]))How a stream works
Open the socket, then send start within 10 seconds — one text message saying what audio follows. A status message arrives first, then started once the transcription service is ready (usually within a second or two). From there, send audio as binary messages at the speed it plays, and read partial (text that may still change) and final (text that won't) as they arrive. Send {"type": "finalize"} when the audio ends, read done and then translation if you asked for one, and let the server close the socket with code 1000. If anything goes wrong after the socket opens, you get one error message instead and nothing follows it. Only one stream runs per socket — open a new one to transcribe more audio after done.
Connecting
There are no query parameters and no other headers. If the key is missing, malformed, wrong, or revoked, the socket never opens — the server answers the handshake with a plain HTTP response instead, which most WebSocket libraries surface as an exception rather than a normal connection event (InvalidStatus in Python websockets, the unexpected-response event in Node ws).
| HTTP status | code | Meaning |
|---|---|---|
| 401 | INVALID_API_KEY | The key is missing, malformed, wrong, or revoked |
| 503 | SERVICE_UNAVAILABLE | The key couldn't be checked right now — try again shortly |
Keep-alive. The server pings every 20 seconds and closes a connection that doesn't answer within 20 seconds. Python websockets and Node ws answer pings for you; a hand-rolled client needs to answer each one with a pong.
The start message
{
"type": "start",
"audio": {"encoding": "pcm_s16le", "sample_rate": 16000, "channels": 1},
"options": {"endpoint_silence_ms": 800, "partials": true},
"target_language": "yo"
}| Field | Required | Value |
|---|---|---|
| audio.encoding | Yes | pcm_s16le only — raw signed 16-bit little-endian samples, no file header |
| audio.sample_rate | Yes | A whole number from 8,000 to 48,000. 16,000 is a good default for speech. |
| audio.channels | Yes | 1 only — mix stereo down to mono first |
| options.endpoint_silence_ms | No | 200–3,000. How long a pause ends a segment. Omit for the default. |
| options.partials | No | true (default) or false. With false you only receive final messages. |
| target_language | No | Same 18 codes as POST /api/v1/tts's target_language — the whole transcript is translated once, after done. |
A field not in this table is refused, including model_id — and so is any value outside its range. Both close the stream with an error.
Sending audio
Binary messages only, raw PCM in the format start declared — at most 65,536 bytes each, always an even number of bytes. A quarter-second chunk is a good size (8,000 bytes at 16,000 Hz). Send it at the speed it plays: the server accepts up to twice that speed plus a 5-second allowance at the start, and faster closes the stream with RATE_LIMITED. Audio sent before started is read once the stream opens and counts against that same 5-second allowance — if you're capturing a live mic while you wait, buffer at most 5 seconds of it. Keep sending through silence: a stream that carries less than 5 seconds of audio in any 30-second window ends with done and reason idle_timeout. Finish with the text message {"type": "finalize"} — the only text message allowed after start; anything else ends the stream with INVALID_INPUT. An empty binary message is ignored.
Messages you receive
Every server message is text, holding one JSON object with a type — ignore a type you don't recognize, since new ones may be added.
| type | When | Fields |
|---|---|---|
| status | Once, before started | state: "connecting" |
| started | The stream is open and ready for audio | protocol, stream_id, model_id, audio, target_language, limits |
| partial | Text for the open segment, may still change | segment, text, audio_seconds |
| final | Text for a segment that won't change again | segment, text, start_seconds, end_seconds |
| done | The stream ended in an orderly way | reason, stream_id, job_id, segments, audio_seconds, billable_blocks, credits_charged |
| translation | After done, only if start named a target_language | status, target_language, text, user_message |
| error | The stream was refused or cut | error: the usual error body |
Speech is split into segments at natural pauses, numbered from 0. Show the latest partial in place and replace it with the final that carries the same segment number — the full transcript is every final's text in segment order, joined with spaces. job_id on done equals stream_id and is null when the stream produced no final — then nothing is stored and nothing is charged. done.reason is finalized, max_duration, credit_exhausted, or idle_timeout — for the latter two, open a new stream to continue (topping up first for credit_exhausted). Read started.limits rather than hardcoding any of the numbers above; they can change.
How a stream ends, and errors
Decide from the messages, not the close code — the code only groups the cases. 1000 is an orderly end (done, then translation if asked for). 1008, 1011, and 1013 are preceded by an error message carrying the real detail. 1009 (a message over 65,536 bytes) and 1012 (the server is restarting — open a new stream) carry no error at all.
| code | Close | When | What to do |
|---|---|---|---|
| INVALID_INPUT | 1008 | No start within 10s; an unknown field or out-of-range value; a second start or any text other than finalize; an odd-byte audio message | Fix the client |
| UNSUPPORTED_FORMAT | 1008 | encoding isn't pcm_s16le, channels isn't 1, or sample_rate is outside 8,000–48,000 | Convert the audio |
| INVALID_LANGUAGE_PAIR | 1008 | target_language isn't supported — details.supported lists every code | Use a listed code |
| RATE_LIMITED | 1008 | A limit was reached — see the three forms below | Wait, then open a new stream |
| QUOTA_EXCEEDED | 1008 | Your balance doesn't cover one 15-second block | Top up |
| SUBSCRIPTION_INACTIVE | 1008 | Your subscription isn't in good standing | Settle it on the website |
| SPENDING_LIMIT_REACHED | 1008 | A limit set on the key or your membership is used up | Raise the limit or wait for it to reset |
| ORGANISATION_INACTIVE | 1008 | The organisation that owns the key is suspended | Contact your organisation's administrator |
| PRODUCT_NOT_ENABLED | 1008 | The organisation's agreement doesn't include speech-to-text | Contact your organisation's administrator |
| NOT_FOUND | 1008 | The account was closed or suspended mid-stream | — |
| SERVICE_UNAVAILABLE | 1013 | Transcription is unavailable or busy, or a check couldn't run | Try again in a minute |
| PROVIDER_ERROR | 1011 | The stream was cut after it started — details.resume_from_seconds says how far it got | Open a new stream from that point |
| INTERNAL_ERROR | 1011 | The stream failed on our side | Try again; report the trace_id if it repeats |
RATE_LIMITED comes in three forms, told apart by details: {"limit": 240, "window_seconds": 86400, "retry_after_seconds": ...} means you've opened your daily number of streams (240, resetting at 00:00 UTC); {"scope": "open_streams", "limit": 4} means too many of your streams are open at once; and the message "audio arrived faster than the stream allows" with empty details means audio was sent faster than the pace rule allows. A WebSocket has no Retry-After header, so read the wait from retry_after_seconds instead. The daily limit and the account/organisation checks run before start, so an error can arrive before you've sent anything.
After the stream
A stream that produced at least one final is stored as a transcription job under stream_id (same value as done.job_id) — read it back the same way as an uploaded file, with GET /api/v1/asr/{job_id} above. A stream keeps no audio, so audio_available is always false on that response. If the stream ends with an error after producing text, the server still stores the segments it had — the stored transcript can hold one segment more than you received, since the last open one is closed and saved before the socket closes. If it produced no final at all, nothing is stored and that route answers 404 JOB_NOT_FOUND.
After a PROVIDER_ERROR cut, everything up to details.resume_from_seconds was transcribed and already sent to you as final messages — open a new stream and send the audio from that point on to recover. Each stream is billed on its own, so audio you send again on the new stream is billed again.
Credits and limits
Same 15-second billing block as the upload route, but held differently: when the stream opens, the cost of the most it may carry (600 seconds, or less if your balance covers less) is held up front, and started.limits.granted_audio_seconds shows the smaller figure if your balance is the constraint. When the stream ends, only the audio it actually carried is charged and the rest is released — done.billable_blocks and done.credits_charged give the final numbers. A stream that produced no final isn't charged; one that produced text and then hit an error is still charged for the audio it carried. Translation costs nothing extra.
| Limit | Value | Per |
|---|---|---|
| Streams opened | 240 a day, resetting at 00:00 UTC | account, or organisation for an organisation's keys |
| Streams open at once | 4 | account |
| Streams open at once, organisation | 15 | organisation |
| Audio in one stream | 600 seconds | stream |
| One audio message | 65,536 bytes | message |
| Audio speed | twice real time, after a 5-second allowance | stream |
| Idle rule | at least 5 seconds of audio in every 30 seconds | stream |
| Time to send start | 10 seconds | stream |
| Streams asking for a translation | 120 a day | account |
start first, as text, within 10 seconds; waits for started before sending audio; sends 16-bit mono PCM in even-sized messages of at most 65,536 bytes, at the speed it plays; sends finalize at the end and keeps reading until the server closes; keeps stream_id and logs every error's code and trace_id; and ignores message types it doesn't recognize.Usage
GET /api/v1/analytics/daily-stats counts how many calls your account made on one UTC day. An optional date query param (YYYY-MM-DD, defaults to today) picks which day; anything else is 400 INVALID_INPUT. The response is {date, total, success, failed, by_endpoint}, where by_endpoint breaks the total down by label — tts_api/asr_api for calls made with a key, and web_ui/web_asr for the same account's activity on the website — different buckets, don't conflate them.
A job counts as a success here the moment it's accepted, even if it later fails — this endpoint tells you how much you called the API, not how those calls turned out; poll the job's own status for that. No route reports your credit balance — that's on the Account page only.
Rate limits
Two limits sit in front of every request: a burst limit per IP address, enforced at the gateway before it reaches the API, and a request limit per account (not per key — two keys on one account share one allowance), reset daily at 00:00 UTC. Nearly every Bearer-key route has its own daily (or per-minute) cap, plus a few caps on how much you run at once — the conversation route's concurrent streams, and how many times one stream ticket can be redeemed. The exact numbers move as the platform grows, so read them from the response rather than hardcoding them:
A 429 carries details.limit, details.window_seconds, and usually details.retry_after_seconds, plus a Retry-After header with the seconds left in the window — on a daily limit that's the time until midnight UTC, not a full day. A couple of caps (three concurrent conversation streams; a ticket past its redemption limit) don't send Retry-After at all: a stream slot frees when its response ends, including on hang-up, and a ticket past its limit should be re-minted, not waited on.
These limits are the same on every plan, and there's currently no way to raise them. Upgrading a plan or buying a top-up adds credits; it doesn't raise these counts. If you're building something these caps don't fit, and tell us about it.
Every error — rate limits included — has the same shape: {"error":{"code","message","user_message","service","trace_id","details"}}. Branch on code — it's stable — never on message (developer text, for logs) or user_message (a sentence that's safe to show a person as-is): both can be reworded without notice. service tells apart two refusals sharing one code — either the API itself, or the gateway in front of it, "nginx-ingress", which produces a few answers on its own (an unknown path, a body over its size limit, a burst from one address) in this same shape. One answer breaks that pattern: a browser preflight from a disallowed origin never reaches the API at all, and comes back as plain text — 400 Disallowed CORS origin, no JSON envelope. Log trace_id (also sent as the X-Request-Id header) alongside your own request id; it's what support needs to find the request server-side, together with the route and roughly when it happened.
| Status | code | What it means |
|---|---|---|
| 400 | INVALID_INPUT | A header/value the schema can't check — a bad Idempotency-Key, a non-audio file, a malformed date |
| 400 | TEXT_TOO_LONG | Over the character cap, or splits into too many segments — see details.limit vs details.chunk_cap |
| 401 | INVALID_API_KEY | Any key problem at all |
| 402 | QUOTA_EXCEEDED | Not enough credits — top up |
| 402 | SUBSCRIPTION_INACTIVE | Subscription not in good standing — settle it on the Account page |
| 402 | SPENDING_LIMIT_REACHED | Organisation key only — a tenant admin's per-member/per-key cap is used up; only they can raise it |
| 404 | NOT_FOUND | Unknown path, or an unknown/expired stream ticket |
| 404 | JOB_NOT_FOUND | Unknown job, or not this account's |
| 404 | VOICE_NOT_FOUND | No voice by that name |
| 405 | METHOD_NOT_ALLOWED | Right path, wrong HTTP method |
| 409 | ALREADY_EXISTS | A request with this Idempotency-Key is still running, or this ticket is already streaming |
| 409 | IDEMPOTENCY_KEY_REUSED | This key belongs to a different request, or is spent — send a new one |
| 410 | ALREADY_DELETED | The uploaded recording has been deleted; the transcript is still there |
| 410 | NOT_FOUND | The account behind a stream ticket is closed or suspended |
| 413 | PAYLOAD_TOO_LARGE | Body over the route's (or the gateway's) size limit |
| 422 | INVALID_INPUT | Doesn't match the schema — details.errors names each field |
| 422 | INVALID_LANGUAGE_PAIR | Unsupported target language, or translation isn't configured — see details.reason |
| 429 | RATE_LIMITED | A limit or a concurrency cap was reached — wait for Retry-After |
| 500 | INTERNAL_ERROR | A fault on our side — retry, and ask for help with trace_id if it lasts |
| 502 | PROVIDER_ERROR | Something we depend on answered badly — retry shortly |
| 503 | SERVICE_UNAVAILABLE | Something we depend on is unavailable — details.dependency names it; retry shortly |
| 504 | PROVIDER_TIMEOUT | Something we depend on didn't answer in time — retry |
Examples
These use /api/v1/tts/prepare instead of /api/v1/tts — it's the fastest path to a finished file (no polling): mint a ticket, then fetch it. /api/v1/tts is the one to reach for instead when you want to fire off a job and check on it later.
# 1. Mint a streaming ticket curl -s -X POST "https://api.yarngpt.ai/api/v1/tts/prepare" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "text": "Ọlájídé sọ pé ó yẹ kí ìjọba tètè dá sí àtúnṣe.", "voice": "umar", "output_format": "mp3" }' # → {"ticket":"...","stream_url":"/api/v1/tts/stream/...","expires_in":300} # 2. Fetch it — no auth header on this leg, the ticket is the credential curl -N "https://api.yarngpt.ai/api/v1/tts/stream/<ticket>" --output yarn.mp3
These snippets skip error handling to stay short — production code needs more. Step 1 can return a 500 INTERNAL_ERROR that's worth retrying once after a short delay (the opposite of the usual rule for a 500 — it means the provider catalogue needs a moment, not that something's broken). Step 2 can return 400 TEXT_TOO_LONG (see the Text-to-Speech section above) or a transient 503 when the server is at capacity (retry once, honouring Retry-After) — check the response status before treating the body as audio, and check its byte count too, since a silently truncated or whitespace-only result still comes back 200.
Endpoint reference
Every route an API key can call.
Output formats by route
| Format | Where it works | Content-Type |
|---|---|---|
| mp3 | everywhere (the default) | audio/mpeg |
| wav | everywhere | audio/wav |
| pcm | prepare/stream, conversation — not the tts job route | audio/l16; rate=<N>; channels=1 — read the rate from the header |
| opus | prepare/stream only | audio/ogg |
| flac | prepare/stream only | audio/flac |
Response headers you may see
| Header | When | Meaning |
|---|---|---|
| X-Request-Id | every response | Same value as the error envelope's trace_id — log it |
| Retry-After | a 429 from a per-account limit | Seconds left in the window (not sent on every 429 — see Rate limits) |
| Location | a stream's 303 | The signed link to the stored audio — follow it |
| X-Yarn-Partial | a stream's 303, sometimes | Present when the stored audio is incomplete |
| X-Synthesis-Warning | a conversation response, sometimes | Comma-separated warning codes, since there's no JSON body to carry them |
| Content-Disposition | a conversation response | A suggested filename |
Text-to-Speech (Bearer key)
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/tts | Queue a synthesis job (poll for the result) |
| POST | /api/v1/tts/prepare | Mint a low-latency streaming ticket |
| GET | /api/v1/tts/stream/{ticket} | Stream the synthesized audio |
| POST | /api/v1/streaming/conversation | Real-time single-turn synthesis |
| GET | /api/v1/status/{job_id} | Poll a queued job's status |
| GET | /api/v1/voices | List available voices |
| GET | /api/v1/voices/{name} | Look up one voice |
| GET | /api/v1/analytics/daily-stats | Your key's usage for today |
Speech-to-Text (Bearer key)
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/asr/languages | List the languages transcription covers |
| POST | /api/v1/asr | Upload audio, get a job id and a price quote |
| GET | /api/v1/asr/{job_id} | Poll a transcription job's status and transcript |
| GET | /api/v1/asr/{job_id}/audio | A short-lived signed link to your uploaded audio |
| GET | /api/v1/asr/stream | WebSocket upgrade — live transcription as audio is sent |