YarnGPT

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_KEY

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

There's no webhook or callback, and no route lists your jobs. Polling is the only way to find out how a job is doing — store every 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/tts

Request body

ParameterTypeRequiredDescription
textstringYesText to convert. Max 15,000 characters.
voicestringNoA 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_formatstringNomp3 (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_languagestringNoTranslate 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/conversation

Body: 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.

The sample rate isn't fixed. Read it out of the response header — 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/asr

Unlike every other route on this page, the body is multipart/form-data, not JSON — one part, file.

ParameterTypeRequiredDescription
filefileYesChecked 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_languagestringNoTranslate 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)stringYes1 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}/audio

Returns {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.

Unknown, malformed, and someone-else's job ids all answer the same way. 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/stream

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

WhatWhere it comes from
Base URLGiven with your account. Replace https:// with wss:// for the socket.
API keySame sk_live_... key as every other route on this page.
CreditsEvery 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 statuscodeMeaning
401INVALID_API_KEYThe key is missing, malformed, wrong, or revoked
503SERVICE_UNAVAILABLEThe 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"
}
FieldRequiredValue
audio.encodingYespcm_s16le only — raw signed 16-bit little-endian samples, no file header
audio.sample_rateYesA whole number from 8,000 to 48,000. 16,000 is a good default for speech.
audio.channelsYes1 only — mix stereo down to mono first
options.endpoint_silence_msNo200–3,000. How long a pause ends a segment. Omit for the default.
options.partialsNotrue (default) or false. With false you only receive final messages.
target_languageNoSame 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.

typeWhenFields
statusOnce, before startedstate: "connecting"
startedThe stream is open and ready for audioprotocol, stream_id, model_id, audio, target_language, limits
partialText for the open segment, may still changesegment, text, audio_seconds
finalText for a segment that won't change againsegment, text, start_seconds, end_seconds
doneThe stream ended in an orderly wayreason, stream_id, job_id, segments, audio_seconds, billable_blocks, credits_charged
translationAfter done, only if start named a target_languagestatus, target_language, text, user_message
errorThe stream was refused or cuterror: 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.

codeCloseWhenWhat to do
INVALID_INPUT1008No start within 10s; an unknown field or out-of-range value; a second start or any text other than finalize; an odd-byte audio messageFix the client
UNSUPPORTED_FORMAT1008encoding isn't pcm_s16le, channels isn't 1, or sample_rate is outside 8,000–48,000Convert the audio
INVALID_LANGUAGE_PAIR1008target_language isn't supported — details.supported lists every codeUse a listed code
RATE_LIMITED1008A limit was reached — see the three forms belowWait, then open a new stream
QUOTA_EXCEEDED1008Your balance doesn't cover one 15-second blockTop up
SUBSCRIPTION_INACTIVE1008Your subscription isn't in good standingSettle it on the website
SPENDING_LIMIT_REACHED1008A limit set on the key or your membership is used upRaise the limit or wait for it to reset
ORGANISATION_INACTIVE1008The organisation that owns the key is suspendedContact your organisation's administrator
PRODUCT_NOT_ENABLED1008The organisation's agreement doesn't include speech-to-textContact your organisation's administrator
NOT_FOUND1008The account was closed or suspended mid-stream—
SERVICE_UNAVAILABLE1013Transcription is unavailable or busy, or a check couldn't runTry again in a minute
PROVIDER_ERROR1011The stream was cut after it started — details.resume_from_seconds says how far it gotOpen a new stream from that point
INTERNAL_ERROR1011The stream failed on our sideTry 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.

LimitValuePer
Streams opened240 a day, resetting at 00:00 UTCaccount, or organisation for an organisation's keys
Streams open at once4account
Streams open at once, organisation15organisation
Audio in one stream600 secondsstream
One audio message65,536 bytesmessage
Audio speedtwice real time, after a 5-second allowancestream
Idle ruleat least 5 seconds of audio in every 30 secondsstream
Time to send start10 secondsstream
Streams asking for a translation120 a dayaccount
Before you go live, check that your client: sends 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.

StatuscodeWhat it means
400INVALID_INPUTA header/value the schema can't check — a bad Idempotency-Key, a non-audio file, a malformed date
400TEXT_TOO_LONGOver the character cap, or splits into too many segments — see details.limit vs details.chunk_cap
401INVALID_API_KEYAny key problem at all
402QUOTA_EXCEEDEDNot enough credits — top up
402SUBSCRIPTION_INACTIVESubscription not in good standing — settle it on the Account page
402SPENDING_LIMIT_REACHEDOrganisation key only — a tenant admin's per-member/per-key cap is used up; only they can raise it
404NOT_FOUNDUnknown path, or an unknown/expired stream ticket
404JOB_NOT_FOUNDUnknown job, or not this account's
404VOICE_NOT_FOUNDNo voice by that name
405METHOD_NOT_ALLOWEDRight path, wrong HTTP method
409ALREADY_EXISTSA request with this Idempotency-Key is still running, or this ticket is already streaming
409IDEMPOTENCY_KEY_REUSEDThis key belongs to a different request, or is spent — send a new one
410ALREADY_DELETEDThe uploaded recording has been deleted; the transcript is still there
410NOT_FOUNDThe account behind a stream ticket is closed or suspended
413PAYLOAD_TOO_LARGEBody over the route's (or the gateway's) size limit
422INVALID_INPUTDoesn't match the schema — details.errors names each field
422INVALID_LANGUAGE_PAIRUnsupported target language, or translation isn't configured — see details.reason
429RATE_LIMITEDA limit or a concurrency cap was reached — wait for Retry-After
500INTERNAL_ERRORA fault on our side — retry, and ask for help with trace_id if it lasts
502PROVIDER_ERRORSomething we depend on answered badly — retry shortly
503SERVICE_UNAVAILABLESomething we depend on is unavailable — details.dependency names it; retry shortly
504PROVIDER_TIMEOUTSomething 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

FormatWhere it worksContent-Type
mp3everywhere (the default)audio/mpeg
waveverywhereaudio/wav
pcmprepare/stream, conversation — not the tts job routeaudio/l16; rate=<N>; channels=1 — read the rate from the header
opusprepare/stream onlyaudio/ogg
flacprepare/stream onlyaudio/flac

Response headers you may see

HeaderWhenMeaning
X-Request-Idevery responseSame value as the error envelope's trace_id — log it
Retry-Aftera 429 from a per-account limitSeconds left in the window (not sent on every 429 — see Rate limits)
Locationa stream's 303The signed link to the stored audio — follow it
X-Yarn-Partiala stream's 303, sometimesPresent when the stored audio is incomplete
X-Synthesis-Warninga conversation response, sometimesComma-separated warning codes, since there's no JSON body to carry them
Content-Dispositiona conversation responseA suggested filename

Text-to-Speech (Bearer key)

MethodPathDescription
POST/api/v1/ttsQueue a synthesis job (poll for the result)
POST/api/v1/tts/prepareMint a low-latency streaming ticket
GET/api/v1/tts/stream/{ticket}Stream the synthesized audio
POST/api/v1/streaming/conversationReal-time single-turn synthesis
GET/api/v1/status/{job_id}Poll a queued job's status
GET/api/v1/voicesList available voices
GET/api/v1/voices/{name}Look up one voice
GET/api/v1/analytics/daily-statsYour key's usage for today

Speech-to-Text (Bearer key)

MethodPathDescription
GET/api/v1/asr/languagesList the languages transcription covers
POST/api/v1/asrUpload 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}/audioA short-lived signed link to your uploaded audio
GET/api/v1/asr/streamWebSocket upgrade — live transcription as audio is sent
Prefer a guided walkthrough?The Quickstart gets you from API key to first audio in five short steps. Open Quickstart