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 as Authorization: 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.com session, so the same endpoints answer a browser without a key.
A Bearer route never redirects. A bad or missing credential is a 401, never a 302 to a sign-in page — an agent following a redirect into HTML is how integrations break silently.

POST /api/speak

FieldTypeNotes
textstringRequired. Up to 5,000 characters.
voice_idstring?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.
modelenumfast · balanced · rich. Defaults to fast. The engine-specific ids this API originally shipped are still accepted.
languagestring?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.
formatenummp3 · wav. Defaults to mp3.
pronunciationsobject?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

No engine returns word timings. They are derived, one of two ways, and every response says which in 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 startMs is its first character's start; its endMs is its last character's end.
  • Times are rounded to whole milliseconds, and endMs is never below startMs.
  • 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.
This exists because transcribers mishear names. On the first live call, “Ounie” came back as “Oni” from one engine and “AONI” from another — both fluent, both correctly timed, both wrong on the one word that mattered. Returning the transcript's words would have shipped a caption track that misspells your brand, silently, and it would have been worst on the pronunciations feature. So it never returns them.

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/voices returns 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 you transcribed. 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.

ModelPriceLanguagesCharacter
fast10 cr / 1,000 chars32Lowest latency. The right default for anything an agent speaks in a loop.
balanced10 cr / 1,000 chars32A little more expressive than Fast, at the same price.
rich20 cr / 1,000 chars29The 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_url is 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.
  • mp3 and wav are both offered, but not by every engine — GET /api/voices reports what the active one can produce. Exact sample rates and bitrates vary by engine; the Content-Type on 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_result and 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

EndpointWhat it does
GET /api/runsYour 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>/audio302 to a fresh signature for that run's audio. Free.
GET /api/voicesThe voice catalogue. Public, free.
GET /api/pricingCurrent prices and limits. Public, free.
GET /api/creditsYour 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_…
ToolCost
speakper 1,000 characters
estimate_speech_costfree
get_speechfree
list_speechfree
list_voicesfree · public
get_credit_balancefree
get_pricingfree · public
whoamifree

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 4xx with 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_result unsettled.
  • 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

StatusBodyMeaning
400text_required · text_too_long · invalid_voice · invalid_model · invalid_language · invalid_format · invalid_pronunciationsSomething about the request is always fatal. Fix it and retry. No price is quoted.
401unauthorizedNo usable credential. Bearer routes never redirect.
402insufficient_creditsCarries required_credits, balance_credits and buy_credits_url.
402thin_result (x402 only)Audio came back without timings. Your payment was not settled.
429too_many_runningMore than 5 runs in flight for one owner.
502synthesis_failedThe engine broke mid-call. You were not charged.
503upstream_unavailableThe engine is refusing work. Nothing was held or quoted.
503service_unavailableSpeech is not configured on this deployment.

Limits

LimitValue
Characters per call5,000
Pronunciation entries per call25
Concurrent runs per owner5
Active API keys per owner5
Price10 cr / 1,000 chars (20 on the rich engine)

Credits are shared across every Ounie app and bought at ounie.com/dashboard/settings.