Stream speech (SSE)
curl --request POST \
--url https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"text": "<string>",
"language": "<string>",
"output_format": "<string>",
"sample_rate": 123,
"num_step": 123,
"expressiveness": 123
}
'import requests
url = "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream"
payload = {
"text": "<string>",
"language": "<string>",
"output_format": "<string>",
"sample_rate": 123,
"num_step": 123,
"expressiveness": 123
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
text: '<string>',
language: '<string>',
output_format: '<string>',
sample_rate: 123,
num_step: 123,
expressiveness: 123
})
};
fetch('https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'text' => '<string>',
'language' => '<string>',
'output_format' => '<string>',
'sample_rate' => 123,
'num_step' => 123,
'expressiveness' => 123
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream"
payload := strings.NewReader("{\n \"text\": \"<string>\",\n \"language\": \"<string>\",\n \"output_format\": \"<string>\",\n \"sample_rate\": 123,\n \"num_step\": 123,\n \"expressiveness\": 123\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"text\": \"<string>\",\n \"language\": \"<string>\",\n \"output_format\": \"<string>\",\n \"sample_rate\": 123,\n \"num_step\": 123,\n \"expressiveness\": 123\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"text\": \"<string>\",\n \"language\": \"<string>\",\n \"output_format\": \"<string>\",\n \"sample_rate\": 123,\n \"num_step\": 123,\n \"expressiveness\": 123\n}"
response = http.request(request)
puts response.read_body{
"Content-Type": "<string>",
"X-Billing-Remaining": "<string>",
"start": {},
"chunk": {},
"done": {},
"error": {}
}Endpoints
Stream speech (SSE)
Stream synthesized audio as Server-Sent Events for low-latency playback.
POST
/
flam-voice-svc
/
api
/
v1
/
public
/
voices
/
{id}
/
stream
Stream speech (SSE)
curl --request POST \
--url https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"text": "<string>",
"language": "<string>",
"output_format": "<string>",
"sample_rate": 123,
"num_step": 123,
"expressiveness": 123
}
'import requests
url = "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream"
payload = {
"text": "<string>",
"language": "<string>",
"output_format": "<string>",
"sample_rate": 123,
"num_step": 123,
"expressiveness": 123
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
text: '<string>',
language: '<string>',
output_format: '<string>',
sample_rate: 123,
num_step: 123,
expressiveness: 123
})
};
fetch('https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'text' => '<string>',
'language' => '<string>',
'output_format' => '<string>',
'sample_rate' => 123,
'num_step' => 123,
'expressiveness' => 123
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream"
payload := strings.NewReader("{\n \"text\": \"<string>\",\n \"language\": \"<string>\",\n \"output_format\": \"<string>\",\n \"sample_rate\": 123,\n \"num_step\": 123,\n \"expressiveness\": 123\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"text\": \"<string>\",\n \"language\": \"<string>\",\n \"output_format\": \"<string>\",\n \"sample_rate\": 123,\n \"num_step\": 123,\n \"expressiveness\": 123\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"text\": \"<string>\",\n \"language\": \"<string>\",\n \"output_format\": \"<string>\",\n \"sample_rate\": 123,\n \"num_step\": 123,\n \"expressiveness\": 123\n}"
response = http.request(request)
puts response.read_body{
"Content-Type": "<string>",
"X-Billing-Remaining": "<string>",
"start": {},
"chunk": {},
"done": {},
"error": {}
}Streams synthesis as Server-Sent Events so you can start playing audio before the full clip is
ready. The request body is identical to
/speak; the response is an
text/event-stream body instead of a single file.
Authorization
string
required
Your workspace API key (
flv_…).Path parameters
string
required
The voice id.
Body
string
required
The text to synthesize. Maximum 10,000 characters.
string
BCP-47 language code (for example
en, hi). Defaults to the voice’s primary language.string
Chunk encoding. One of
s16le, s24le, s32le, f32le, f64le (raw, headerless PCM). Chunks
arrive as base64-encoded PCM either way; the format decides the sample width.integer
default:24000
Output sample rate in Hz. Defaults to the provider’s native rate (
24000); values above it are
upsampled. The effective rate is reported in the start event.integer
default:32
Number of inference steps,
1–32 (a quality/latency trade-off). Provider default (32) if
omitted.number
Style/expressiveness control,
0.0–1.0. Provider default if omitted.Response headers
string
text/event-stream (with Cache-Control: no-cache and X-Accel-Buffering: no so proxies don’t
buffer the stream).string
Your remaining character allowance for the month (present when billing is active). Streaming is
metered per character of
text, same as /speak.Event format
Each event is delimited per the SSE spec (event: line, data: JSON line, blank line):
event: start
data: {"type":"start","sample_rate":24000,"audio_format":"s16le"}
event: chunk
data: {"type":"chunk","index":0,"audio_b64":"<base64 PCM>"}
event: chunk
data: {"type":"chunk","index":1,"audio_b64":"<base64 PCM>"}
event: done
data: {"type":"done","audio_duration_s":3.42,"generation_id":"665f1a2b3c4d5e6f7a8b9f10","audio_url":"https://storage.googleapis.com/.../speech.wav"}
event
Sent once at the beginning.
sample_rate is the effective output rate and audio_format the
PCM encoding of the chunks - use these (not what you requested) to configure playback.event
One per audio chunk, in order.
index is the 0-based position; audio_b64 is base64-encoded raw
PCM audio to append to your buffer.event
Sent once when synthesis is complete; no more chunks follow. Carries
audio_duration_s (total
seconds of audio), and - when object storage is enabled - generation_id and a public audio_url
of the full clip as a WAV file, so you can link or re-download it without reassembling the chunks.event
Sent if synthesis fails. Includes a provider
code and message. The stream ends after it.Streaming requires the
streaming feature on your plan; without it the request returns 403 when
billing enforcement is active, and an exhausted character allowance returns 402 - see
Errors. Streamed synthesis is never served from cache.curl -N -X POST \
"https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/665f1a2b3c4d5e6f7a8b9c0d/stream" \
-H "X-API-Key: $FLAM_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"Streaming hello from Finesse.","language":"en"}'
const id = "665f1a2b3c4d5e6f7a8b9c0d";
const res = await fetch(
`https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/${id}/stream`,
{
method: "POST",
headers: {
"X-API-Key": process.env.FLAM_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ text: "Streaming hello from Finesse.", language: "en" }),
},
);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const line = frame.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
const ev = JSON.parse(line.slice(5).trim());
if (ev.type === "chunk") {
const pcm = Buffer.from(ev.audio_b64, "base64"); // append to your audio buffer
} else if (ev.type === "done") {
break;
} else if (ev.type === "error") {
throw new Error(ev.message ?? ev.code);
}
}
}
import os, base64, json, requests
voice_id = "665f1a2b3c4d5e6f7a8b9c0d"
with requests.post(
f"https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{voice_id}/stream",
headers={"X-API-Key": os.environ["FLAM_API_KEY"], "Content-Type": "application/json"},
json={"text": "Streaming hello from Finesse.", "language": "en"},
stream=True,
) as res:
res.raise_for_status()
for raw in res.iter_lines():
if not raw or not raw.startswith(b"data:"):
continue
ev = json.loads(raw[5:].strip())
if ev["type"] == "chunk":
pcm = base64.b64decode(ev["audio_b64"]) # append to your audio buffer
elif ev["type"] == "done":
break
elif ev["type"] == "error":
raise RuntimeError(ev.get("message") or ev.get("code"))
Prompt
Copy this into your coding agent (Claude Code, Cursor, Codex…) to implement this endpoint.You are implementing a typed client for one Finesse Voice API endpoint: "Stream speech (SSE)".
Everything you need is in this prompt. Do not invent anything that is not specified here.
WHAT TO BUILD
- A function streamSpeech(voiceId, params, options) (name it per the project's conventions) that
streams synthesis as Server-Sent Events and exposes typed events in order, as an async iterator
or callbacks, whichever fits the stack. Support cancellation (AbortSignal or equivalent).
- A convenience helper that consumes the stream and returns { pcm: bytes, sampleRate, audioFormat,
audioDurationS, audioUrl?, generationId? }.
- Use a streaming HTTP read (fetch + res.body reader in TypeScript, requests stream=True / httpx
stream in Python). Do not add an EventSource dependency: EventSource cannot send POST bodies or
custom headers.
- Detect the stack first: look for package.json, tsconfig.json, pyproject.toml,
requirements.txt, go.mod, Gemfile, composer.json and similar. Use the project's language, HTTP
client, module layout, error-handling conventions and test runner. If there is no project yet or
the stack is unclear, use TypeScript on Node 18+ with the built-in fetch and no new dependencies.
- If the project already has a Finesse API client module, add this to it (reuse its auth, base
URL, error type and retry helper) instead of creating a parallel one.
- Keep the base URL (https://fi.production.flamapis.com) in one constant or config value.
ENDPOINT
- Method: POST
- URL: https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices/{id}/stream
- Request Content-Type: application/json
- Success response Content-Type: text/event-stream (with Cache-Control: no-cache and
X-Accel-Buffering: no so proxies do not buffer)
- The voice must be "ready" and belong to the key's workspace.
PARAMETERS
- id (path, string, required): the voice id, a 24-character hex string (for example
"665f1a2b3c4d5e6f7a8b9c0d"). Validate it locally against /^[0-9a-f]{24}$/i and URL-encode it.
REQUEST BODY (same fields as the one-shot /speak endpoint, except output_format)
- text (string, required): maximum 10,000 characters (check locally, counting Unicode code points;
reject empty text).
- language (string, optional): BCP-47 code (e.g. "en", "hi"). Defaults to the voice's
primary_language.
- output_format (string, optional): chunk encoding, one of "s16le", "s24le", "s32le", "f32le",
"f64le" (raw, headerless PCM). Chunks arrive base64-encoded either way; the format decides the
sample width. Do not offer "wav" for streaming.
- sample_rate (integer, optional, default 24000): output rate in Hz; values above the native 24000
are upsampled. The EFFECTIVE rate is reported in the start event.
- num_step (integer, optional, default 32): 1-32, quality vs latency.
- expressiveness (number, optional): 0.0-1.0. Provider default when omitted.
- Omit optional fields the caller did not set.
{ "text": "Streaming hello from Finesse.", "language": "en" }
AUTHENTICATION
- Send the header X-API-Key: <workspace API key> on every request. Keys start with "flv_".
- Read the key from the environment variable FLAM_API_KEY (or the project's existing secrets or
config mechanism). Fail fast with a clear error if it is missing, before making any request.
- Never hard-code, log or commit the key, and never ship it in browser/client-side code: call this
from server-side code only. The key can synthesize with every voice in its workspace.
- A key is bound to one workspace. Referencing a voice or job from another workspace returns 404.
EVENT STREAM FORMAT
Each event follows the SSE spec: an "event:" line, a "data:" line with JSON, then a blank line.
event: start
data: {"type":"start","sample_rate":24000,"audio_format":"s16le"}
event: chunk
data: {"type":"chunk","index":0,"audio_b64":"<base64 PCM>"}
event: chunk
data: {"type":"chunk","index":1,"audio_b64":"<base64 PCM>"}
event: done
data: {"type":"done","audio_duration_s":3.42,"generation_id":"665f1a2b3c4d5e6f7a8b9f10","audio_url":"https://storage.googleapis.com/.../speech.wav"}
Event types (dispatch on the JSON "type" field):
- start: sent once first. sample_rate is the EFFECTIVE output rate and audio_format the PCM
encoding of the chunks. Use these, not what you requested, to configure playback.
- chunk: one per audio chunk, in order. index is the 0-based position; audio_b64 is base64 raw PCM
to append to your buffer. Append in arrival order and do not fail if index is missing.
- done: sent once when synthesis is complete; no more chunks follow. Carries audio_duration_s
(total seconds of audio) and, when object storage is enabled, generation_id and a public
audio_url of the full clip as a WAV file (optional fields).
- error: sent if synthesis fails, with a provider "code" and "message". The stream ends after it;
surface it as a typed error.
Parser requirements:
- Buffer across network reads; split events on blank lines; accept both \n and \r\n line endings;
join multiple "data:" lines of one event with \n; ignore comment lines starting with ":" and
unknown event types.
- If the stream closes without a done or error event, treat it as an incomplete-stream error, not
success.
ERRORS BEFORE THE STREAM STARTS
A non-200 status means no stream: read the body as JSON (never as SSE). Errors use the standard
envelope:
{
"status": 400,
"error": true,
"message": "text too long (max 10000 chars)"
}
Billing and plan denials do NOT use the envelope. They return a flat JSON body whose "error"
field is a string, not a boolean:
{ "error": "plan limit reached", "feature": "max_voices", "limit": 10 }
{ "error": "quota_exhausted", "unit": "character", "limit": 100000, "used": 100000 }
The "error" string is the denial reason (for example quota_exhausted, insufficient_credits,
limit_reached, feature_disabled, or one of the messages above). Tell the two shapes apart by
checking whether "error" is a boolean (envelope) or a string (flat denial), and expose
feature / unit / limit / used on your error type when present.
- 400: invalid request, text over 10,000 characters, or the voice is not ready yet. Do not retry.
- 401: X-API-Key missing, malformed, invalid or revoked. Do not retry.
- 402: character allowance exhausted (flat body). Do not retry.
- 403: the plan does not include the "streaming" feature (when billing enforcement is active), or
another plan/permission denial (flat body). Do not retry.
- 404: voice not found, including voices owned by another workspace. Do not retry.
- 422: the provider rejected the input. Do not retry.
- 429: rate limited. 502: provider warming up or unavailable. Retry these with backoff.
- Surface failures as one typed error (for example FinesseApiError) carrying: HTTP status, message
(the envelope "message" or the flat "error" string), optional feature / unit / limit / used, a
retryable flag, and the raw body for debugging.
- Parse error bodies defensively: if a body is not valid JSON, keep the raw text in the error.
RETRIES
- Retry 429, 502 and network errors ONLY if no event has been received yet, with exponential
backoff plus jitter (at most 3 retries). The first request after a long idle can be slow while
the engine warms up; use a generous, configurable time-to-first-event timeout.
- Never auto-retry after the stream has started or after an error event: streamed synthesis is
never served from cache, so every attempt is billed again. Surface the error and let the caller
decide.
- Never retry 400, 401, 402, 403, 404 or 422.
BILLING
- Metered per character of text, same as /speak, against the monthly allowance.
- Requires the "streaming" feature on the plan.
- Capture X-Billing-Remaining (remaining characters, present when billing is active) as an
optional number on the result.
ACCEPTANCE CRITERIA
- streamSpeech yields typed start / chunk / done / error events in order and decodes chunk audio
from base64 to bytes.
- The helper returns the concatenated PCM with the effective sample_rate and audio_format from the
start event, plus audio_duration_s, audio_url and generation_id from done when present.
- Pre-stream failures are parsed as JSON into the typed error; error events and incomplete streams
reject; cancellation aborts the HTTP request.
- The key is never logged or sent from client-side code.
TEST PLAN
- Unit tests feeding a fake SSE body split at awkward byte boundaries (mid-line, mid-JSON, CRLF):
start + 3 chunks + done; an error event; a stream that ends without done; unknown event ignored;
pre-stream 403 flat body and 400 envelope; retry on 502 before the first event; no retry after a
chunk was received.
- Smoke script:
- Get a ready voice id from FLAM_VOICE_ID, or by calling GET https://fi.production.flamapis.com/flam-voice-svc/api/v1/public/voices (same
X-API-Key header; voices are in data.voices) and picking the first one whose status is "ready".
- Stream "Streaming hello from Finesse.", print the start event, the number of chunks, total PCM
bytes, audio_duration_s and audio_url, and the time to the first chunk. Exit non-zero on error.
RULES
- Implement only what this spec describes. Do not invent parameters, fields, headers, endpoints or
error codes. If something is ambiguous, missing, or conflicts with the existing codebase, stop
and ask me before guessing.
- Match the project's existing style and conventions; do not refactor unrelated code.

