← API reference
Code examples
Text to Speech

TTS API reference

HTTP and WebSocket contracts for voices, synthesis, streaming events, audio output, errors, and production behavior.

Start here

Choose one-shot or streaming

Use HTTP one-shot when the complete text is ready. Use WebSocket streaming when text arrives incrementally or playback should begin before generation finishes. Both paths use the same voices, authentication, formats, and audit ledger.

1. Authenticate

Send your workspace API key as a bearer token.

2. Choose a voice

List available Narya voice IDs and supported formats.

3. Synthesize

Use HTTP for one result or WebSocket for streamed chunks.

Before you begin

API keyAn Owner or Admin creates it in the Dashboard sidebar under Config → Integrations. See Authentication below for every step.
Voice IDCall GET /v1/voices and use an id returned for the same workspace and environment.
Server runtimeNode.js 20+ or Python 3.11+ for the examples below.
voice_id is the only synthesis choice
Send the Narya voice_id. Do not send model IDs, provider names, provider voice references, or routing policies; Narya resolves those server-side.

Environment setup

Keep development and production separate

Choose the environment before configuring credentials. The WebSocket endpoint and API key are an inseparable pair.

EnvironmentBase URL / WebSocketCredential boundary
Developmenthttps://animated-waffle-dev.narya.ai
wss://animated-waffle-dev.narya.ai/v1/tts/stream
Development keys and development-approved voices only.
Productionhttps://animated-waffle.narya.ai
wss://animated-waffle.narya.ai/v1/tts/stream
Production keys and production-approved voices only.
Keys are not portable between environments
A development key is expected to fail against production, and a production key must not be used for development testing. Change NARYA_TTS_BASE_URL, NARYA_TTS_WS_URL, andNARYA_TTS_API_KEY together.

Create your environment file

Start with the development pair below, then replace both endpoint and key when Narya approves production access. Discover voices again after switching environments.

.env
# Use URLs and an API key from the same environment.
NARYA_TTS_BASE_URL=https://animated-waffle-dev.narya.ai
NARYA_TTS_WS_URL=wss://animated-waffle-dev.narya.ai/v1/tts/stream
NARYA_TTS_API_KEY=awp_...

# Choose an exact voice from GET /v1/voices. This is a Narya ID, not an
# upstream provider reference. The examples derive its output format at runtime.
NARYA_VOICE_ID=your_narya_voice_id

Voice discovery

List the voices you can use

Voice IDs are stable Narya identifiers scoped to your workspace. The response intentionally does not expose upstream providers, models, or provider voice references.

GET /v1/voices
curl --request GET \
  --url "$NARYA_TTS_BASE_URL/v1/voices" \
  --header "Authorization: Bearer $NARYA_TTS_API_KEY"
200 · application/json · example response
{
  "voices": [
    {
      "id": "<voice-id-for-your-workspace>",
      "name": "Example voice",
      "output_formats": ["opus_48000_64", "pcm_16000", "pcm_44100"],
      "capabilities": ["tts", "voice_tags"]
    }
  ]
}
FieldTypeRequiredDescription
voices[].idstringYesPass this Narya-owned value as voice_id to one-shot or streaming TTS.
voices[].namestringYesHuman-readable display name.
voices[].output_formatsstring[]YesFormats this voice can deliver without provider-specific substitution.
voices[].capabilitiesstring[]YesFeatures supported by this exact voice variant. Check for voice_tags before sending expressive cues.
Recommended default: opus_48000_64
Prefer opus_48000_64 for modern realtime transport when the selected voice lists it. It preserves full-band audio while reducing bandwidth. Use pcm_44100 when the downstream system explicitly requires uncompressed raw PCM, or pcm_16000 for a 16 kHz constraint. The quickstart derives the format from that exact voice and fails when none is available.
Do not copy provider voice IDs
A value from Fish Audio, ElevenLabs, or another upstream service is not a Naryavoice_id. If a voice is absent here, it is not enabled for this key.
Example IDs are placeholders
This page does not define a universal preset voice. Copy an actualvoices[].id from your authenticated response; never send the placeholder values shown in protocol examples.

5-minute quickstart

Make your first request

The examples discover the selected voice, prefer Opus when it supports Opus, and otherwise use its first advertised format before making one TTS request.

01

Discover

Choose an exact voice id from GET /v1/voices.

02

Send

Derive its format, then POST the TTS request.

03

Save

Write the response body as audio bytes.

curl · one-shot
# Requires jq. Fails if the exact voice is missing or advertises no formats.
output_format="$(curl --fail-with-body --silent --show-error \
  --url "$NARYA_TTS_BASE_URL/v1/voices" \
  --header "Authorization: Bearer $NARYA_TTS_API_KEY" \
  | jq --arg voice_id "$NARYA_VOICE_ID" --exit-status --raw-output '
      [.voices[] | select(.id == $voice_id)][0]
      | if . == null then error("Selected voice not found")
        elif (.output_formats | index("opus_48000_64")) != null then "opus_48000_64"
        elif (.output_formats | length) > 0 then .output_formats[0]
        else error("Selected voice has no output_formats")
        end
    ')"
test -n "$output_format" || { echo "Could not select an output format" >&2; exit 1; }

curl --fail-with-body --request POST \
  --url "$NARYA_TTS_BASE_URL/v1/tts" \
  --header "Authorization: Bearer $NARYA_TTS_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "text": "Hello from Narya.",
    "voice_id": "'"$NARYA_VOICE_ID"'",
    "output_format": "'"$output_format"'"
  }' \
  --dump-header response-headers.txt \
  --output output.audio
oneshot.ts
import { writeFile } from 'node:fs/promises'

const baseUrl = process.env.NARYA_TTS_BASE_URL
const apiKey = process.env.NARYA_TTS_API_KEY
const voiceId = process.env.NARYA_VOICE_ID
if (!baseUrl || !apiKey || !voiceId) throw new Error('Missing Narya environment variables')

const voicesResponse = await fetch(`${baseUrl}/v1/voices`, {
  headers: { Authorization: `Bearer ${apiKey}` },
})
if (!voicesResponse.ok) throw new Error(`Voice discovery failed: ${voicesResponse.status}`)
const { voices } = await voicesResponse.json() as {
  voices: Array<{ id: string; output_formats: string[] }>
}
const voice = voices.find((candidate) => candidate.id === voiceId)
if (!voice) throw new Error(`Voice not found: ${voiceId}`)
const outputFormat = voice.output_formats.includes('opus_48000_64')
  ? 'opus_48000_64'
  : voice.output_formats[0]
if (!outputFormat) throw new Error(`Voice has no output formats: ${voiceId}`)

const response = await fetch(`${baseUrl}/v1/tts`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'Hello from Narya.',
    voice_id: voiceId,
    output_format: outputFormat,
  }),
})
if (!response.ok) throw new Error(`Narya TTS failed: ${response.status} ${await response.text()}`)

await writeFile('output.audio', Buffer.from(await response.arrayBuffer()))
console.log('request', response.headers.get('x-request-id'))
console.log('audit', response.headers.get('x-audit-id'))
Expected result
The command prints an audit ID and writes output.audio. UseX-Audio-Format to choose the decoder or final filename. An HTTP error returns the same structured error fields used by streaming.

Framework guide

Use Narya TTS with Pipecat

Use a small TTSService adapter when Pipecat owns transport, turn-taking, interruption, and playback. The adapter below converts Narya audio events into the raw PCM frames Pipecat expects.

1. Request PCM

Ask Narya for pcm_44100 so no compressed decoder sits in the realtime path.

2. Validate delivery

Require event.format to match the sample-rate-qualified PCM format you requested.

3. Match playback

Set PipelineParams.audio_out_sample_rate to the TTS service sample rate.

install
python -m pip install "pipecat-ai==1.4.0" "websockets>=15,<17"
narya_tts_service.py
import base64
import json
import uuid
from collections.abc import AsyncGenerator

from pipecat.frames.frames import ErrorFrame, Frame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame
from pipecat.services.settings import TTSSettings
from pipecat.services.tts_service import TTSService
from websockets.asyncio.client import connect


class NaryaTTSService(TTSService):
    def __init__(self, *, endpoint: str, api_key: str, voice_id: str, sample_rate: int = 44100, **kwargs) -> None:
        super().__init__(
            sample_rate=sample_rate,
            settings=TTSSettings(model="narya", voice=voice_id, language=None),
            **kwargs,
        )
        self.endpoint = endpoint
        self.api_key = api_key
        self.voice_id = voice_id
        self.sample_rate = sample_rate
        self.partner_session_id = f"pipecat_{uuid.uuid4()}"

    async def run_tts(self, text: str, context_id: str) -> AsyncGenerator[Frame | None, None]:
        yield TTSStartedFrame()
        try:
            async with connect(
                self.endpoint,
                additional_headers={"Authorization": f"Bearer {self.api_key}"},
                open_timeout=10,
                max_size=None,
            ) as websocket:
                await websocket.send(json.dumps({
                    "event": "start",
                    "request": {
                        "partner_session_id": self.partner_session_id,
                        "context_id": context_id,
                        "voice_id": self.voice_id,
                        "output_format": f"pcm_{self.sample_rate}",
                    },
                }))
                await websocket.send(json.dumps({"event": "text", "text": text}))
                await websocket.send(json.dumps({"event": "stop"}))

                async for raw in websocket:
                    event = json.loads(raw)
                    if event["event"] == "audio":
                        expected = f"pcm_{self.sample_rate}"
                        if event["format"] != expected:
                            raise ValueError(f"Expected {expected}, got {event['format']}")
                        yield TTSAudioRawFrame(
                            audio=base64.b64decode(event["audio"]),
                            sample_rate=self.sample_rate,
                            num_channels=1,
                            context_id=context_id,
                        )
                    elif event["event"] == "error":
                        yield ErrorFrame(error=f"narya_tts_error:{event['code']}")
                        break
                    elif event["event"] == "finish":
                        break
        except Exception as error:
            yield ErrorFrame(error=f"narya_tts_transport_error:{error}")
        yield TTSStoppedFrame()
pipeline wiring
import os

from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker

# transport, stt, llm, and context_aggregator come from your existing bot.
output_sample_rate = int(os.getenv("NARYA_TTS_SAMPLE_RATE", "44100"))
tts = NaryaTTSService(
    endpoint=os.environ["NARYA_TTS_WS_URL"],
    api_key=os.environ["NARYA_TTS_API_KEY"],
    voice_id=os.environ["NARYA_VOICE_ID"],
    sample_rate=output_sample_rate,
)

pipeline = Pipeline([
    transport.input(), stt, context_aggregator.user(), llm,
    tts, transport.output(), context_aggregator.assistant(),
])
worker = PipelineWorker(
    pipeline,
    params=PipelineParams(
        audio_in_sample_rate=16000,
        audio_out_sample_rate=output_sample_rate,
        enable_metrics=True,
        enable_usage_metrics=True,
    ),
)
Require an explicit PCM sample rate
TTSAudioRawFrame carries decoded PCM. Acceptpcm_<sample-rate> or inspect a WAV header. Reject barepcm because its sample rate is ambiguous; decode compressed audio before creating the frame.
Keep the API key in the bot process
The Pipecat server sends the bearer header during the WebSocket upgrade. Never forward the permanent key to the browser transport or include it in RTVI client parameters.

Security

Authentication and access

Authenticate during the WebSocket upgrade. Permanent keys belong only in trusted server environments.

Server-to-server

Send the key as a bearer token in the WebSocket upgrade request. Store it in your secret manager and rotate it through the Agent Dashboard.

Authorization: Bearer awp_...

Browser or mobile

Never embed a permanent key in a website or app bundle. Your trusted backend either proxies TTS itself, or mints a short-lived streaming token (below) and hands only that token to the client.

Short-lived streaming tokens
Your backend can call POST /v1/tts/stream-tokens with the workspace key to receive { token, expires_at, websocket_url }. The token is valid for 2 minutes, opens exactly one /v1/tts/streamconnection (as ?token= or a bearer header), and never exposes the permanent key. Browser connections are additionally checked against a platform Origin allowlist — ask Narya to allowlist your web origin before shipping a browser client.

Create a production key

  1. 1Open the production Agent Dashboard and sign in. Creating keys requires a workspace Owner or Admin role.
  2. 2Select Integrations in the sidebar (under the Config group). The page is titled API keys.
  3. 3Enter a descriptive Label such as checkout-voice-prod and select Create key.
  4. 4Copy the full awp_ value from the "Key created. Copy it now, it won't be shown again." banner. Only its hash is retained.
  5. 5Transfer it through your approved secret-sharing channel and store it server-side. Revoke compromised keys from the Issued keys list; revocation is immediate.
Open production Agent Dashboard

HTTP guide

One-shot TTS

POST one complete text input and receive audio bytes directly in the response body. Use this for notifications, prerecorded prompts, previews, and any request where the full text is already available.

Node.js 20+
import { writeFile } from 'node:fs/promises'

const baseUrl = process.env.NARYA_TTS_BASE_URL
const apiKey = process.env.NARYA_TTS_API_KEY
const voiceId = process.env.NARYA_VOICE_ID
if (!baseUrl || !apiKey || !voiceId) throw new Error('Missing Narya environment variables')

const voicesResponse = await fetch(`${baseUrl}/v1/voices`, {
  headers: { Authorization: `Bearer ${apiKey}` },
})
if (!voicesResponse.ok) throw new Error(`Voice discovery failed: ${voicesResponse.status}`)
const { voices } = await voicesResponse.json() as {
  voices: Array<{ id: string; output_formats: string[] }>
}
const voice = voices.find((candidate) => candidate.id === voiceId)
if (!voice) throw new Error(`Voice not found: ${voiceId}`)
const outputFormat = voice.output_formats.includes('opus_48000_64')
  ? 'opus_48000_64'
  : voice.output_formats[0]
if (!outputFormat) throw new Error(`Voice has no output formats: ${voiceId}`)

const response = await fetch(`${baseUrl}/v1/tts`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    text: 'Hello from Narya.',
    voice_id: voiceId,
    output_format: outputFormat,
  }),
})
if (!response.ok) throw new Error(`Narya TTS failed: ${response.status} ${await response.text()}`)

await writeFile('output.audio', Buffer.from(await response.arrayBuffer()))
console.log('request', response.headers.get('x-request-id'))
console.log('audit', response.headers.get('x-audit-id'))
FieldTypeRequiredDescription
textstringConditionalComplete UTF-8 text, at most 20,000 characters. May be empty only when params.voice_tags is present and the selected voice advertises voice_tags.
voice_idstringYesNarya voice ID returned by GET /v1/voices.
output_formatenumYesA format listed for this voice.
partner_session_idstringNoOptional external correlation ID. Omit it when you do not have one.
context_idstringNoYour turn or playback-context ID.
params.speedpositive numberNoSpeaking-speed multiplier when supported by the selected voice.
params.voice_tagsstring[]NoOne or two provider-neutral emotion or nonverbal cues. Send bare values without square brackets.
response bodyHTTP 200
Raw audio bytes. Use Content-Type andX-Audio-Format to select the decoder or filename.
response headersHTTP 200
X-Request-Id, X-Audit-Id,X-Audio-Format, and X-Audio-Duration-Ms.
Errors are JSON, not audio
For non-2xx responses, parse the JSON error object and use itsretryable value. Do not save an error body as an audio file.

Expressive speech

Emotion tags by provider

Send emotion, delivery, and nonverbal cues separately from spoken text through params.voice_tags. Narya converts them to the selected provider's syntax after resolving the voice.

Send tags separately from text
Put one or two bare values in params.voice_tags, without square brackets. Keep using the Narya voice_id; do not send a provider or model override. Check voices[].capabilities forvoice_tags. When a voice does not support tags, Narya removes structured and inline bracket cues before provider dispatch and speaks the remaining text. A tag-only request still returns invalid_request because no speakable text remains.
one-shot · text with a tag
{
  "text": "I understand how frustrating this has been.",
  "voice_id": "<voice-id-from-get-v1-voices>",
  "output_format": "opus_48000_64",
  "params": {
    "voice_tags": ["empathetic"]
  }
}
one-shot · tag-only sound
{
  "text": "",
  "voice_id": "<voice-id-from-get-v1-voices>",
  "output_format": "opus_48000_64",
  "params": {
    "voice_tags": ["panting", "gasping"]
  }
}
Streaming uses the same request field
Add request.params.voice_tags to thestart event. The tags apply to the complete WebSocket request. For tag-only audio, send start with tags and thenstop without a text event.
streaming · start event
{
  "event": "start",
  "request": {
    "partner_session_id": "conversation_42",
    "voice_id": "<voice-id-from-get-v1-voices>",
    "output_format": "opus_48000_64",
    "params": {
      "voice_tags": ["panting"]
    }
  }
}

Fish Audio S2

Narya adds Fish Audio's square brackets server-side. Send only the inner value. S2 accepts concise natural-language descriptions such asvery excited, so this reference is not a fixed enum.

S2 routes only

Basic emotions

happysadangryexcitedcalmnervousconfidentsurprisedsatisfieddelightedscaredworriedupsetfrustrateddepressedempatheticembarrasseddisgustedmovedproudrelaxedgratefulcurioussarcastic

Advanced emotions

disdainfulunhappyanxioushystericalindifferentuncertaindoubtfulconfuseddisappointedregretfulguiltyashamedjealousenvioushopefuloptimisticpessimisticnostalgiclonelyboredcontemptuoussympatheticcompassionatedeterminedresigned

Delivery & human sounds

softbreathyin a hurry toneshoutingscreamingwhisperingsoft toneemphasislaughingmoaningchucklingsobbingcrying loudlysighinggroaningpantinggaspingyawningsnoringclear throataudience laughingbackground laughtercrowd laughingpauselong pausebreaklong-break
params.voice_tags: ["empathetic"]
Official reference: 49 emotions plus delivery and sound markers.Fish Audio emotion control docs

ElevenLabs · eleven_v3

ElevenLabs calls these Audio Tags. They are natural-language instructions rather than an enum, and results vary with the selected voice and its training samples. Narya adds the provider syntax; send only the bare tag value and test it before production use.

eleven_v3 only

Voice & emotion

laughslaughs harderstarts laughingwheezingwhisperssighsexhalessarcasticcuriousexcitedcryingsnortsmischievously

Sound effects

gunshotapplauseclappingexplosionswallowsgulps

Experimental

strong X accentsingswoofart
params.voice_tags: ["curious", "sighs"]
Officially recommended examples; the list is intentionally not exhaustive.ElevenLabs prompting guide

waffle 1.0 beta

Emotion tag support for waffle 1.0 beta is not available yet. Narya removes bracket cues before sending text to this model, so the cue is never read aloud by the appliance.

Coming soon
Practical starting point
Begin with one primary cue and test the exact voice. The Narya API accepts at most two tags per request, each up to 48 characters. Tags apply to the whole request; split synthesis into separate requests when the expression must change mid-sentence.

Protocol guide

Connection lifecycle

A WebSocket carries exactly one synthesis request. Control messages are JSON text frames; audio is returned inside JSON events as base64.

  1. 1ClientconnectOpen the production WSS endpoint with the bearer token. A rejected credential receives an unauthorized error and close code 1008.
  2. 2ClientstartDeclare attribution, the Narya voice ID, and output format exactly once. Narya assigns the request ID and returns it on server events.
  3. 3ClienttextSend one or more non-empty text segments unless this is a tag-only request. Segments are appended in wire order.
  4. 4ClientflushOptional. Synthesize the current buffer now and keep the request open for more text.
  5. 5ServeraudioReceive zero or more ordered chunks. The first sequence number is 1.
  6. 6ClientstopSynthesize any remaining text, or a tag-only request, and finalize the request.
  7. 7Serverusage → finishRead the terminal metadata, then allow the server to close normally. An error replaces finish when the request fails.
Valid ordering
start → (text+ → (flush → text+)*)? → stop
Terminal events
Treat finish or error as final. Do not send more control events afterward.

Low-latency input

Stream text without hurting prosody

Use flush at natural phrase boundaries when text arrives incrementally. This starts synthesis without ending the request.

json lines · after start
{"event":"text","text":"Your order is ready, "}
{"event":"flush"}
{"event":"text","text":"and it will arrive tomorrow."}
{"event":"stop"}

Flush complete phrases

Prefer clauses or sentences over individual LLM tokens. Include spaces and punctuation in the text exactly where they should be spoken.

Balance latency and context

Smaller flushes can reduce time to first audio but give the model less linguistic context. Begin with sentence boundaries, then tune using measured playback latency.

  • Each text event may contain at most 5,000 characters.
  • The production request limit is 20,000 characters across all text events.
  • An empty buffer on flush is a no-op.
  • stop flushes the remaining buffer or starts tag-only synthesis; a separate final flush is unnecessary.
  • Open a new WebSocket for the next utterance; Narya assigns it a new request ID.

Playback guide

Handle audio output correctly

Every audio event includes the delivered format. Decode base64, preserve sequence order, and route chunks using the request or context ID.

server · audio event
{
  "event": "audio",
  "request_id": "rq_018f...",
  "context_id": "assistant_turn_7",
  "seq": 1,
  "audio": "AAABAAIA...",
  "format": "pcm_44100"
}
Requested formatNominal encodingTypical use
pcm_16000Raw 16-bit PCM · 16 kHzExplicitly 16 kHz-constrained pipelines
pcm_44100Raw 16-bit PCM · 44.1 kHzRaw PCM compatibility and decoder-free pipelines
aac_44100_128AAC · 44.1 kHz · 128 kbpsMobile and media pipelines
opus_48000_64Ogg Opus · 48 kHz · 64 kbpsRecommended default for modern realtime transport
The event format is authoritative
Inspect event.format for every request and reject unexpected values. Narya does not silently substitute a different format for a voice.

Playback rules

  • WebSocket messages arrive in order; still use seq to detect missing or duplicated application-level chunks.
  • Compressed chunks can be concatenated only when your decoder supports the delivered stream format.
  • Raw PCM has no container header. Add the appropriate WAV header only when a file or player requires one.
  • opus_48000_64 is the recommended default when the selected voice supports it; decode the delivered Ogg Opus stream at 48 kHz.
  • Use pcm_44100 for raw PCM compatibility; it is mono, signed 16-bit little-endian at 44,100 Hz.
  • Treat bare pcm as invalid because it does not identify a sample rate. Never infer it from the requested format.
  • Do not mark playback complete when the socket closes; mark synthesis complete on finish.

Production guide

Production readiness checklist

A production client needs explicit timeout, retry, observability, and playback decisions around the basic protocol.

Secrets

Keep permanent keys in a server-side secret manager; never log or ship them.

Timeouts

Set a connection timeout and a terminal-event timeout appropriate to your utterance length.

Identifiers

Log the returned request_id with partner_session_id, context_id, and audit_id.

Retry budget

Retry only retryable errors with exponential backoff and jitter.

Replay safety

Prevent completed or partially played speech from replaying unintentionally.

Backpressure

Bound decoded audio queues so slow playback cannot grow memory without limit.

Cancellation

Close the WebSocket when the user interrupts; do not automatically replay cancelled speech.

Format validation

Select decoding from event.format and test every format enabled for your workspace.

API reference

Start request

The first control frame declares the complete request configuration. Send start exactly once per connection.

client · start event
{
  "event": "start",
  "request": {
    "partner_session_id": "conversation_42",
    "context_id": "assistant_turn_7",
    "voice_id": "<voice-id-returned-by-GET-/v1/voices>",
    "output_format": "pcm_44100"
  }
}
FieldTypeRequiredDescription
event"start"YesDiscriminator for the first control event.
request.partner_session_idstringNoOptional external audit correlation ID, 1–200 characters. It never owns billing or idempotency.
request.context_idstringNoYour turn, message, or playback-context ID. Echoed on audio events when supplied.
request.voice_idstringYesNarya voice identifier returned by GET /v1/voices.
request.output_formatenumYesRequested audio format. See Handle audio output.
request.params.speedpositive numberNoOptional speaking-speed multiplier when supported by the selected voice.
request.params.voice_tagsstring[]NoOne or two request-wide expressive cues, without square brackets. Required when no text event will be sent.
request_id is server-owned
Do not send a request ID in start. Narya generates a UUID-based ID for every connection and includes it in every audio, usage, finish, or error event. It owns Narya billing and idempotency. Log it for support, but do not supply or reuse it as a client retry key.

API reference

Client events

After start, send text and optional flush events, then one stop event. All client messages must be JSON text frames.

text1 or more
Adds a non-empty text string to the current buffer. Maximum 5,000 characters per event.
flushoptional
Synthesizes the current buffer and keeps the request open. Has no additional fields.
stopexactly once
Synthesizes remaining text, emits terminal metadata, and ends the request.
FieldTypeRequiredDescription
text.event"text"YesText-event discriminator.
text.textstringYesNon-empty UTF-8 text, at most 5,000 characters.
flush.event"flush"YesFlush-event discriminator. No other fields.
stop.event"stop"YesStop-event discriminator. No other fields.

API reference

Server events

A successful request emits audio events, then usage and finish. A failed request emits one terminal error instead of finish.

audio0 or more
Base64 audio bytes plus request ID, optional context ID, one-based sequence number, and delivered format.
usageonce on success
Character and generated-audio totals plus the Narya voice ID.
finishterminal success
Final reason, pass-through cost, currency, audit ID, and persistence status.
errorterminal failure
Stable code, optional route and detail, and the retryability decision for this failure.
server · usage
{
  "event": "usage",
  "request_id": "rq_018f...",
  "usage": { "chars": 48, "audio_ms": 2760 },
  "voice_id": "<voice-id-returned-by-GET-/v1/voices>"
}
server · finish
{
  "event": "finish",
  "request_id": "rq_018f...",
  "reason": "stop",
  "cost": { "amount": 0.00072, "currency": "USD", "pass_through": true },
  "audit_id": "rq_018f...",
  "audio_persisted": false
}
server · error
{
  "event": "error",
  "request_id": "rq_018f...",
  "code": "provider_unavailable",
  "retryable": true,
  "detail": "The requested voice is temporarily unavailable."
}
FieldTypeRequiredDescription
audio.seqintegerAudioOne-based chunk sequence across the complete request, including multiple flushes.
audio.audiobase64 stringAudioEncoded bytes for this chunk.
audio.formatstringAudioDelivered wire format. Use this value to choose the decoder.
usage.usage.charsintegerUsageCharacters synthesized successfully across all segments.
usage.usage.audio_msintegerUsageGenerated audio duration when measurable for the selected voice.
finish.audit_idstringFinishAudit correlation ID to include in support requests.
finish.audio_persistedbooleanFinishWhether Narya retained the generated audio. API v1 normally returns false.
error.retryablebooleanErrorAuthoritative signal for whether a new attempt is safe.

Reliability reference

Errors, retries, and limits

Use the error event for application decisions and the WebSocket close code for transport diagnostics.

CodeRetry?Client action
unauthorizedNoCorrect, rotate, or re-scope the credential before reconnecting.
invalid_requestNoFix JSON, event order, configuration, or input limits.
provider_auth_missingNoContact Narya or use an approved configured fallback.
provider_unavailableIf retryable=trueOpen a new connection after exponential backoff with jitter.
provider_errorIf retryable=trueRetry only when explicitly allowed; otherwise escalate with the audit ID.
client_cancelledNo automatic retryExpected after interruption or disconnect; decide from product intent.

Retry contract

A retry is a new connection, and Narya assigns it a newrequest_id. Keep the samepartner_session_id only when it remains part of the same conversation. Never replay audio after a finish event.

Close codes

1008 means authorization or control-policy failure.1009 means the control frame is too large. A successful request closes normally after finish.

Partial audio may already have played
A retryable failure can occur after one or more audio chunks. Your application must decide whether to discard, resume at a product-safe boundary, or replace the utterance; blindly replaying the full request can duplicate speech for the listener.

Release history

Changelog

Contract changes are versioned here so integrations can distinguish additive capabilities from migrations that require client work.

v1.4.0

Capability
  • Added voices[].capabilities so clients can identify exact variants with voice-tag support.
  • Made tag compatibility provider-model specific: Fish S2 and Eleven v3 accept tags; Eleven Multilingual v2 and Waffle do not.
  • Unsupported variants now omit structured and inline cues before provider dispatch; tag-only requests remain invalid.

v1.3.0

Additive
  • Added optional params.voice_tags to one-shot and streaming requests.
  • Added tag-only synthesis by sending text: "" with at least one voice tag.
  • Added server-side provider syntax conversion and explicit model compatibility errors.

v1.2.0

Contract
  • Made request_id server-owned for every one-shot and streaming request.

v1.1.0

Endpoints
  • Added POST /v1/tts and GET /v1/voices with provider-neutral voice IDs.

v1.0.0

Initial
  • Published the initial authenticated WebSocket streaming contract.

Complete example

Node.js / TypeScript

This production-oriented Node.js client adds timeouts, sequence-aware assembly, delivered-format handling, and terminal-state checks.

install
npm install ws
npm install --save-dev tsx typescript @types/ws
stream.ts
import { randomUUID } from 'node:crypto'
import { writeFile } from 'node:fs/promises'
import WebSocket from 'ws'

const required = (name: string) => {
  const value = process.env[name]
  if (!value) throw new Error(`Missing environment variable: ${name}`)
  return value
}
const baseUrl = required('NARYA_TTS_BASE_URL')
const apiKey = required('NARYA_TTS_API_KEY')
const voiceId = required('NARYA_VOICE_ID')
const voicesResponse = await fetch(`${baseUrl}/v1/voices`, {
  headers: { Authorization: `Bearer ${apiKey}` },
})
if (!voicesResponse.ok) throw new Error(`Voice discovery failed: ${voicesResponse.status}`)
const { voices } = await voicesResponse.json() as {
  voices: Array<{ id: string; output_formats: string[] }>
}
const voice = voices.find((candidate) => candidate.id === voiceId)
if (!voice) throw new Error(`Voice not found: ${voiceId}`)
const selectedFormat = voice.output_formats.includes('opus_48000_64')
  ? 'opus_48000_64'
  : voice.output_formats[0]
if (!selectedFormat) throw new Error(`Voice has no output formats: ${voiceId}`)

const ws = new WebSocket(required('NARYA_TTS_WS_URL'), {
  headers: { Authorization: `Bearer ${apiKey}` },
  handshakeTimeout: 10_000,
})
const chunks = new Map<number, Buffer>()
let format = selectedFormat
let completed = false
const timeout = setTimeout(() => ws.terminate(), 30_000)

ws.on('open', () => {
  ws.send(JSON.stringify({
    event: 'start',
    request: {
      partner_session_id: process.env.NARYA_PARTNER_SESSION_ID ?? `session_${randomUUID()}`,
      context_id: 'assistant_turn_1',
      voice_id: voiceId,
      output_format: format,
    },
  }))
  ws.send(JSON.stringify({ event: 'text', text: 'Hello from Narya streaming TTS.' }))
  ws.send(JSON.stringify({ event: 'stop' }))
})

ws.on('message', async (raw) => {
  const event = JSON.parse(raw.toString())
  if (event.event === 'audio') {
    chunks.set(event.seq, Buffer.from(event.audio, 'base64'))
    format = event.format
  } else if (event.event === 'error') {
    throw new Error(`${event.code}: ${event.detail}`)
  } else if (event.event === 'finish') {
    const audio = [...chunks.entries()].sort(([a], [b]) => a - b).map(([, bytes]) => bytes)
    const suffix = format.startsWith('opus') ? 'opus' : format.startsWith('aac') ? 'aac' : 'pcm'
    await writeFile(`output.${suffix}`, Buffer.concat(audio))
    completed = true
    clearTimeout(timeout)
    console.log('request', event.request_id, 'audit', event.audit_id)
    ws.close()
  }
})
ws.on('close', () => {
  clearTimeout(timeout)
  if (!completed) process.exitCode = 1
})
run
NARYA_TTS_BASE_URL=https://animated-waffle-dev.narya.ai \
NARYA_TTS_WS_URL=wss://animated-waffle-dev.narya.ai/v1/tts/stream \
NARYA_TTS_API_KEY=awp_... \
NARYA_VOICE_ID=your_narya_voice_id \
npx tsx stream.ts
Integration boundary
Wrap this transport code in your own speech client so request IDs, metrics, playback queues, cancellation, and retry policy have one owner in your application.