API reference
Speaker API
One synchronous call turns text into speech and returns the audio together with every word's start and end in milliseconds. Three rails — REST, MCP, and keyless x402 — run the same code and bill the same way.
Quickstart
curl -X POST https://speak.ounie.com/api/speak \
-H "Authorization: Bearer spk_live_…" \
-H "Content-Type: application/json" \
-d '{
"text": "Your agent just found its voice.",
"model": "fast",
"format": "mp3"
}'The response carries a signed audio_url, the total duration_ms, and a timings array — one entry per word.
Authentication
- Bearer key. Mint one at /dashboard/api-keys — it looks like
spk_live_…. Send it asAuthorization: Bearer …, or as?api_key=for hosts that cannot set headers. - The fleet master key. Your ounie.com developer key (
ounie_live_…) works here too, once you enable “Use across Ounie apps” on your Ounie settings. One key, the whole fleet. - The session cookie. The dashboard uses the shared
.ounie.comsession, so the same endpoints answer a browser without a key.
401, never a 302 to a sign-in page — an agent following a redirect into HTML is how integrations break silently.POST /api/speak
| Field | Type | Notes |
|---|---|---|
| text | string | Required. Up to 5,000 characters. |
| voice_id | string? | A voice id from GET /api/voices. Omit it and the active engine's default narrator is used. A voice the active engine does not have is refused, never substituted. |
| model | enum | fast · balanced · rich. Defaults to fast. The engine-specific ids this API originally shipped are still accepted. |
| language | string? | ISO-639-1 hint. Refused up front if the chosen rung does not speak it. Engines that accept an explicit language are told; the rest infer it from the text. |
| format | enum | mp3 · wav. Defaults to mp3. |
| pronunciations | object? | Term → respelling, up to 25 entries. Each respelling must be a single word. |
The response
{
"id": "b2f7…",
"status": "succeeded",
"audio_url": "https://… (signed, expires)",
"duration_ms": 2100,
"characters": 32,
"characters_synthesized": 32,
"word_count": 6,
"timings": [
{ "word": "Your", "startMs": 0, "endMs": 260 },
{ "word": "agent", "startMs": 300, "endMs": 700 },
{ "word": "voice.","startMs": 1620, "endMs": 2100 }
],
"timings_source": "provider_alignment",
"timings_derived_from": "Word timings are derived from the engine's character-level alignment…",
"voice": "George",
"voice_id": "JBFqnCBsd6RMkjVDRZzb",
"model": "fast",
"provider": "elevenlabs",
"engine_model": "eleven_flash_v2_5",
"format": "mp3",
"credits_spent": 1,
"thin": false
}characters is what was sent to the engine — after any pronunciation respelling — and it is the number you are billed on. characters_synthesized is what the engine reported speaking, and it is what the charge is settled against.
provider names the engine that produced the audio and engine_model the model it used, while model stays the quality rung you asked for. The distinction matters because voices are engine-specific: if you intend to reuse a voice_id, check that it came from the engine you are calling now.
Word timings — and where they come from
timings_source. This page says it rather than implying a granularity we were handed.timings_source: "provider_alignment" — the engine answered with three parallel arrays, characters[], character_start_times_seconds[] and character_end_times_seconds[]. The reduction is:
- Group the characters on whitespace. A whitespace character ends a word and belongs to none.
- A word's
startMsis its first character's start; itsendMsis its last character's end. - Times are rounded to whole milliseconds, and
endMsis never belowstartMs. - Punctuation stays attached to the word it touches, so a token is usable as a caption exactly as it arrives.
- Where the engine returns both, the alignment describing your text is used — not the one describing its own normalized reading of it.
duration_ms comes from the same alignment's last end time rather than from the audio container, so it can never disagree with the timings printed beside it.
timings_source: "transcribed" — the engine returned audio and nothing else. Speaker transcribes the audio it just produced, at word granularity, and aligns the transcript back onto your text with a sequence alignment. Then:
- The words returned are always yours, with your spelling and your punctuation. The transcript supplies only the clock.
- A word the transcriber did not hear is interpolated across the gap between its timed neighbours and flagged
estimated: true. Absent means observed; there is no third state. - A word the transcriber split in two lengthens the word it belongs to, rather than appearing as a token you never wrote.
- If too little of your text can be found in the transcript, the run is treated as thin and not billed at all.
Engines & failover
Speaker runs a chainof speech engines. A request goes to the first one that is healthy, has your voice and can produce your format; if it refuses, the next one takes over inside the same request. Each engine has its own account and its own circuit breaker, so one supplier's outage is not the product's outage.
- The price never moves. It is a function of the quality rung and the character count, and never of which engine served you. On the keyless rail the payer signs the quoted amount before the work and settling for less is refused after it — a price that could shift with a failover would be a price we could not collect.
- Voices do not carry across. Each engine has its own voices with its own ids.
GET /api/voicesreturns the voices of the engine that would take your call right now, and naming one another engine has is refused rather than swapped. - Formats are a capability, not a promise. An engine that cannot produce the format you asked for is skipped rather than substituting a different one.
- Timings may be derived differently. Engines that return their own alignment give you
provider_alignment; the rest give youtranscribed. The contract — your words, milliseconds, punctuation attached — is identical either way.
If you want to pin the behaviour, read provider and timings_source off the response rather than assuming them. Both are also on every past run.
Voices & models
GET /api/voices is public and free. It returns the catalogue of the engine currently serving requests, with each voice's gender, accent, age and the kind of work it was built for, plus the engine's provider name, the formats it can produce and its timings_source. An available: false means every engine is refusing right now: the list is what you would get once one recovers, not what you can call today.
| Model | Price | Languages | Character |
|---|---|---|---|
| fast | 10 cr / 1,000 chars | 32 | Lowest latency. The right default for anything an agent speaks in a loop. |
| balanced | 10 cr / 1,000 chars | 32 | A little more expressive than Fast, at the same price. |
| rich | 20 cr / 1,000 chars | 29 | The most natural delivery. Costs the engine twice as much, so it costs you twice as much. |
model names a quality rung, not an engine model. Each engine in the chain maps the three rungs onto its own models, and every engine's rich is a genuinely more expensive model on that engine's own rate card — which is what makes charging the premium rate for it honest wherever it runs. The response reports the actual model in engine_model.
Pronunciations
{
"text": "Ounie ships agent tools.",
"pronunciations": { "Ounie": "Oonie" }
}- Each term is replaced in your text before synthesis, as a whole word, case-insensitively. Longer terms are applied first so a short one cannot shadow a longer one containing it.
- It is a textual respelling, not a phoneme dictionary. Nothing is created or stored on the engine.
- The respelling is what the engine reads, so it is what you are billed for — a longer respelling costs slightly more.
- Your own spelling is what comes back in the timings, so captions built from them are publishable as they arrive — on the transcribed path this is structural, since the timings are aligned onto your text in the first place.
- A respelling containing whitespace is refused. Two tokens where you wrote one would put the timings out of step with your script.
Audio delivery
- Objects are private.
audio_urlis a signature minted for that response and it expires. - For something you can keep, use
GET /api/runs/<id>/audio— a permanent, owner-gated URL that redirects to a fresh signature every time. Free. mp3andwavare both offered, but not by every engine —GET /api/voicesreports what the active one can produce. Exact sample rates and bitrates vary by engine; theContent-Typeon the signed URL is authoritative.
Thin results & refunds
A run that produces audio but no usable word timings is thin. Three faults land there — no character alignment, a failed transcription, or a transcript covering too little of your text to be trusted — and none of them is yours. You still receive the audio, and:
- On the credit rail it settles at zero and the hold refunds in full, pool-exact.
- On x402 it returns
402 thin_resultand the payment is never settled — there is no refund on chain, so the refusal has to come first.
This is stricter than “did bytes come back” on purpose. Audio without the clock is not the thing you bought, and it is not something you could have caused.
Availability
A scheduled probe checks every engine independently of any request, on both edges — it heals a breaker that has recovered and opens one that has quietly died. While every engine is refusing, every rail refuses up front: a 503 with nothing held, nothing quoted and no payment signature requested. Quoting a price for work that cannot succeed costs an agent a wallet round trip and costs a person a reserve-and-refund pair in their ledger, for nothing.
Nothing is lost by that refusal: the probe runs on its own schedule, so no caller ever pays to discover an outage, and recovery needs no deploy.
Reading past runs
| Endpoint | What it does |
|---|---|
| GET /api/runs | Your runs, newest first, each with a fresh signed audio URL. Free. |
| GET /api/runs/<id> | One run with its full word timings. Free. |
| GET /api/runs/<id>/audio | 302 to a fresh signature for that run's audio. Free. |
| GET /api/voices | The voice catalogue. Public, free. |
| GET /api/pricing | Current prices and limits. Public, free. |
| GET /api/credits | Your spendable Ounie credits. |
MCP
Endpoint https://speak.ounie.com/api/mcp (legacy SSE: /api/sse)
Auth Authorization: Bearer spk_live_…
# Hosts that cannot set headers (e.g. the Ounie AI Team):
https://speak.ounie.com/api/mcp?api_key=spk_live_…| Tool | Cost |
|---|---|
| speak | per 1,000 characters |
| estimate_speech_cost | free |
| get_speech | free |
| list_speech | free |
| list_voices | free · public |
| get_credit_balance | free |
| get_pricing | free · public |
| whoami | free |
list_voices and get_pricing are public because an agent has to see the catalogue and the price before it can name a voice or budget a call.
x402 — keyless, pay per call
Agents with no Ounie account pay in USDC on Base. The price depends on how much text you sent, so there is no constant to publish: POST the call unpaid and the 402 quotes that call.
- Before quoting. Anything wrong with the request itself — an unoffered model, an unknown voice, a language that engine does not speak, text over the limit — returns a
4xxwith no price in the body. You are never asked to sign for work that cannot succeed. - Before settling. The signature is verified off-chain, the synthesis runs, and only then does the money move. A run with no timings returns
402 thin_resultunsettled. - Never for less than quoted. The quote is computed from the submitted character count and the settlement uses the same number. A reduced settlement would be refused after the work was already done.
curl -X POST https://speak.ounie.com/api/x402/speak \
-H "Content-Type: application/json" \
-d '{"text":"…"}'
# → 402 { "x402Version": 1, "accepts": [{
# "scheme": "exact", "network": "base",
# "maxAmountRequired": "144000",
# "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
# "payTo": "0x…",
# "extra": { "name": "USD Coin", "version": "2" } }] }
curl -X POST https://speak.ounie.com/api/x402/speak \
-H "X-Payment: <base64 signed payload>" \
-H "Content-Type: application/json" \
-d '{"text":"…"}'The rate is 1.2× the credit price — 1,200 characters on a fast model is 12 credits, or $0.144 here. The exact scheme is an offline EIP-3009 authorization — the facilitator pays the gas, so a wallet holding only USDC can pay.
Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | text_required · text_too_long · invalid_voice · invalid_model · invalid_language · invalid_format · invalid_pronunciations | Something about the request is always fatal. Fix it and retry. No price is quoted. |
| 401 | unauthorized | No usable credential. Bearer routes never redirect. |
| 402 | insufficient_credits | Carries required_credits, balance_credits and buy_credits_url. |
| 402 | thin_result (x402 only) | Audio came back without timings. Your payment was not settled. |
| 429 | too_many_running | More than 5 runs in flight for one owner. |
| 502 | synthesis_failed | The engine broke mid-call. You were not charged. |
| 503 | upstream_unavailable | The engine is refusing work. Nothing was held or quoted. |
| 503 | service_unavailable | Speech is not configured on this deployment. |
Limits
| Limit | Value |
|---|---|
| Characters per call | 5,000 |
| Pronunciation entries per call | 25 |
| Concurrent runs per owner | 5 |
| Active API keys per owner | 5 |
| Price | 10 cr / 1,000 chars (20 on the rich engine) |
Credits are shared across every Ounie app and bought at ounie.com/dashboard/settings.