# Overview

# Overview

Welcome to the Fluxions API. Our hosted endpoints cover three product surfaces:

- **Transcription** — `akro-v1`, our listening model: speech-to-text, speaker diarization, and non-speech events (breaths, laughter, hesitations) in one call. Production-ready today.
- **Text-to-Speech** — hosted **VUI** for expressive, low-latency TTS over HTTP or WebSocket. Live today — see [Speech](#speech).
- **Realtime Voice** — end-to-end streaming voice conversations over WebSocket or WebRTC, with your own persona and your own tools. See the [Realtime Voice API reference](https://api.fluxions.ai/vui/docs) and the [bring-your-own-tools guide](https://api.fluxions.ai/vui/docs/agent-tools-api).

This page covers the basics that apply across all surfaces: authentication, base URL, and a health check.

## Authentication

All API requests require authentication using an API key. Include your API key in the `Authorization` header:

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/endpoint" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

headers = {'Authorization': 'YOUR_API_KEY'}
response = requests.get('https://api.fluxions.ai/endpoint', headers=headers)
data = response.json()
```

**JavaScript (.js)**
```javascript
const response = await fetch('https://api.fluxions.ai/endpoint', {
  headers: {'Authorization': 'YOUR_API_KEY'}
});
const data = await response.json();
```

A `Bearer ` prefix is optional — `Authorization: YOUR_API_KEY` and `Authorization: Bearer YOUR_API_KEY` authenticate identically.

## Base URL

```
https://api.fluxions.ai
```

## GET /health — Health Check

Check the API status and version information. *No authentication required.*

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/health"
```

**Python (.py)**
```python
import requests

response = requests.get('https://api.fluxions.ai/health')
data = response.json()
print(f"Status: {data['status']}, Model: {data['model']}")
```

**JavaScript (.js)**
```javascript
const response = await fetch('https://api.fluxions.ai/health');
const data = await response.json();
console.log(`Status: ${data.status}, Model: ${data.model}`);
```

### Response

```json
{
  "status": "ok",
  "gateway": "api.fluxions.ai"
}
```

## GET /billing/balance — Account Balance

Check your remaining balance and the rates you are being charged. Cheap and
read-only, so it is safe to poll — use it to top up *before* your traffic starts
failing rather than discovering the problem through a [402](#errors).

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/billing/balance" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

r = requests.get(
    'https://api.fluxions.ai/billing/balance',
    headers={'Authorization': 'YOUR_API_KEY'},
)
b = r.json()
if b['gated']:
    raise RuntimeError(f"out of credit (${b['balance_usd']:.2f}) — top up")
```

**JavaScript (.js)**
```javascript
const r = await fetch('https://api.fluxions.ai/billing/balance', {
  headers: {'Authorization': 'YOUR_API_KEY'}
});
const b = await r.json();
if (b.gated) throw new Error(`out of credit ($${b.balance_usd.toFixed(2)})`);
```

### Response

```json
{
  "balance_usd": 44.68,
  "balance_microcents": 4468381000,
  "gated": false,
  "plan": null,
  "rates": {
    "tts_per_mchar_usd": 10.0,
    "transcription_per_hour_usd": 0.20,
    "transcription_minimum_usd": 0.01
  }
}
```

| Field | Description |
|-------|-------------|
| `balance_usd` | Remaining balance in dollars |
| `balance_microcents` | The same balance, exact. Money is stored in microcents (1 cent = 10<sup>6</sup>); use this field if you need to avoid float rounding |
| `gated` | `true` when your next billable request will be refused with a 402. Poll this |
| `plan` | Your subscription plan key, or `null` for pay-as-you-go. Subscribers are governed by their plan allowance rather than the wallet, so they are never `gated` here |
| `rates` | What you are charged. `transcription_minimum_usd` is a per-request floor — see [Transcription](#transcription) |

Balances are live: renders are debited as they happen, so the number here
reflects usage up to the moment you asked.



---

# Transcription

# Transcription

Our **akro-v1** model is a comprehensive listening model that performs:

- **Transcription** — Convert speech to text with high accuracy
- **Speaker Diarization** — Identify and separate different speakers ("who said what")
- **Non-Speech Detection** — Capture breathing, laughter, hesitation, and other contextual sounds

This makes it ideal for transcribing meetings, interviews, podcasts, and any audio where understanding the full context matters.

All transcription endpoints require authentication — see [Overview](#overview) for API key setup.

**Pricing:** $0.20 per hour of audio processed, billed by the second, with a **$0.01 minimum per request**.

The floor covers per-request overhead, and it binds on anything under 3 minutes — a 5-second
clip costs the same $0.01 as a 3-minute one. If you are sending many short utterances (a live
voice agent, say), concatenate them into fewer, longer requests to pay the per-second rate
rather than the floor. See [pricing](/pricing).

## POST /transcriptions — Submit Transcription

Submit audio and get a job ID back immediately. Poll `/transcriptions/{id}` for the results: transcription, speaker diarization, and non-speech events.

Send the audio in one of two ways:

- **The file itself** as the request body — any common audio format, up to 100 MB.
- **A URL** to a file you already host (S3, GCS, a CDN): JSON `{"url": "https://..."}`, up to 1 GiB. We fetch it, so it must be publicly reachable or presigned, and served with a `Content-Length`.

### Parameters

Query parameters for a file upload; JSON fields alongside `url` for a URL submit.

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `non_speech` | boolean | `false` | Include non-speech sounds |
| `filename` | string | `"audio"` (upload), the URL's file name (URL) | Name shown in your transcription list |

Submitting a file you have already transcribed with the same settings completes at once from the earlier result, at no charge.

### Request

Upload a file:

**Bash (.sh)**
```bash
curl -X POST "https://api.fluxions.ai/transcriptions?filename=meeting.mp3" \
  -H "Authorization: YOUR_API_KEY" \
  --data-binary @meeting.mp3
```

**Python (.py)**
```python
import requests

with open('meeting.mp3', 'rb') as f:
    response = requests.post(
        'https://api.fluxions.ai/transcriptions',
        params={'filename': 'meeting.mp3'},
        headers={'Authorization': 'YOUR_API_KEY'},
        data=f,
    )

job = response.json()
job_id = job['id']
```

**JavaScript (.js)**
```javascript
// audioFile is a File or Blob — send it as the body itself, not inside FormData
const response = await fetch('https://api.fluxions.ai/transcriptions?filename=meeting.mp3', {
  method: 'POST',
  headers: {'Authorization': 'YOUR_API_KEY'},
  body: audioFile
});

const job = await response.json();
const jobId = job.id;
```

Or submit a URL:

**Bash (.sh)**
```bash
curl -X POST "https://api.fluxions.ai/transcriptions" \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://my-bucket.s3.amazonaws.com/meeting.mp3", "non_speech": true}'
```

**Python (.py)**
```python
import requests

response = requests.post(
    'https://api.fluxions.ai/transcriptions',
    headers={'Authorization': 'YOUR_API_KEY'},
    json={'url': 'https://my-bucket.s3.amazonaws.com/meeting.mp3', 'non_speech': True},
)

job_id = response.json()['id']
```

### Response

```json
{
  "id": 124,
  "status": "submitted",
  "created_at": "2026-10-03T10:35:00.000000",
  "filename": "meeting.mp3",
  "non_speech": false,
  "query_urls": {
    "get_transcription": "https://api.fluxions.ai/transcriptions/124",
    "list_transcriptions": "https://api.fluxions.ai/transcriptions"
  }
}
```

### Errors

| Status | When |
|--------|------|
| `400` | Empty body, a URL that isn't `http(s)`, or a URL that didn't return the file |
| `402` | Your balance is below the $0.01 minimum |
| `411` | The upload was sent without a `Content-Length` (e.g. a chunked stream) — send the file or its bytes |
| `413` | The file is over 100 MB (upload) or 1 GiB (URL) |

The charge itself is made when processing starts, once the audio's duration is known. If your balance can't cover it, the job ends with `status: "failed"` and an `error_message` saying how much was needed.

### Workflow

1. Submit audio to `POST /transcriptions` and receive a job ID
2. Poll `/transcriptions/{id}` to check status
3. When `status` is `"completed"`, retrieve full results

## GET /transcriptions/{id} — Get Transcription Results

Retrieve the full results for a specific job: transcription, speaker diarization, and non-speech events.

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `word_level_timestamps` | boolean | `false` | Include each segment's `words`, with per-word `start`/`end` |

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/transcriptions/124" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

response = requests.get(
    'https://api.fluxions.ai/transcriptions/124',
    headers={'Authorization': 'YOUR_API_KEY'}
)

result = response.json()
if result['status'] == 'completed':
    print(result['text'])
```

**JavaScript (.js)**
```javascript
const response = await fetch(
  'https://api.fluxions.ai/transcriptions/124',
  {
    headers: {'Authorization': 'YOUR_API_KEY'}
  }
);

const result = await response.json();
if (result.status === 'completed') {
  console.log(result.text);
}
```

### Response

```json
{
  "id": 124,
  "status": "completed",
  "created_at": "2025-10-24T10:35:00.000Z",
  "updated_at": "2025-10-24T10:35:20.000Z",
  "filename": "interview.mp3",
  "audio_duration": 300.0,
  "audio_format": "opus",
  "processing_time": 245.5,
  "language": "en",
  "non_speech": false,
  "num_chunks": 11,
  "num_segments": 25,
  "num_speakers": 2,
  "text": "SPEAKER_0: Yeah, let's actually start off exactly, where we initially began.\nSPEAKER_1: Sounds perfect. That makes complete sense to me.\nSPEAKER_0: So I started thinking about what if this is just a construct?",
  "segments": [
    {
      "speaker": "0",
      "text": "Yeah, let's actually start off exactly, where we initially began.",
      "start": 0.86,
      "end": 6.42,
      "segment_idx": 0
    },
    {
      "speaker": "1",
      "text": "Sounds perfect",
      "start": 6.0,
      "end": 7.2,
      "segment_idx": 0
    },
    {
      "speaker": "1",
      "text": "That makes complete sense to me.",
      "start": 7.5,
      "end": 9.8,
      "segment_idx": 1
    }
  ],
  "audio_url": "https://...r2.cloudflarestorage.com/..."
}
```

### Status Values

- `submitted` — Job has been submitted
- `processing` — Transcription in progress
- `completed` — Transcription finished successfully
- `failed` — Transcription failed (check `error_message` — e.g. undecodable audio, or a balance that couldn't cover the charge)

## GET /transcriptions — List Transcriptions

List all transcriptions for your account.

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `limit` | integer | `50` | Number of results per page (max: 100) |
| `offset` | integer | `0` | Pagination offset |

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/transcriptions?limit=10&offset=0" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

response = requests.get(
    'https://api.fluxions.ai/transcriptions',
    headers={'Authorization': 'YOUR_API_KEY'},
    params={'limit': 10, 'offset': 0}
)

data = response.json()
print(f"Total: {data['total']}, Found: {len(data['transcriptions'])} transcriptions")
for t in data['transcriptions']:
    print(f"  ID {t['id']}: {t['filename']} - {t['status']}")
```

**JavaScript (.js)**
```javascript
const response = await fetch(
  'https://api.fluxions.ai/transcriptions?limit=10&offset=0',
  {
    headers: {'Authorization': 'YOUR_API_KEY'}
  }
);

const data = await response.json();
console.log(`Total: ${data.total}, Found: ${data.transcriptions.length} transcriptions`);
data.transcriptions.forEach(t => {
  console.log(`  ID ${t.id}: ${t.filename} - ${t.status}`);
});
```

### Response

```json
{
  "total": 150,
  "limit": 10,
  "offset": 0,
  "transcriptions": [
    {
      "id": 150,
      "status": "completed",
      "created_at": "2025-10-24T10:40:00.000Z",
      "filename": "interview.mp3",
      "audio_duration": 1800.0,
      "audio_format": "opus",
      "processing_time": 45.2,
      "num_speakers": 2,
      "num_segments": 142,
      "original_audio_url": "https://...",
      "language": "en"
    }
  ]
}
```

## Response Format

### Text Field

The `text` field contains the full transcription with speaker labels and optional non-speech events:

- **Speaker Labels**: `SPEAKER_0:`, `SPEAKER_1:`, etc. prefix each speaker's utterances
- **Line Breaks**: Newlines (`\n`) separate different speaker turns
- **Non-speech Events**: When enabled, events like `[breath]`, `[pause]` appear inline

**Example**:
```
SPEAKER_0: Yeah, let's start [breath] where we began.
SPEAKER_1: Sounds good. That makes sense.
SPEAKER_0: So I was thinking about [pause] what if this is a construct?
```

### Segments Array

The `segments` array provides precise timing and speaker information for each utterance:

- **speaker**: Speaker ID as a string (`"0"`, `"1"`, etc.)
- **text**: The spoken text for this segment (without non-speech events)
- **start**: Start time in seconds (decimal precision)
- **end**: End time in seconds (decimal precision)
- **segment_idx**: Sequential index for this segment

## Non-Speech Events

When `non_speech=true`, our listening model captures various non-speech sounds and events that provide additional context to the conversation.

### Common Non-Speech Sounds

| Event | Tag | Description | Example Usage |
|-------|-----|-------------|---------------|
| **Breath** | `[breath]` | Audible breathing sounds | `...end of sentence. [breath] Now this is important.` |
| **Laugh** | `[laugh]` or `hahaha` | Laughter - can be written as text or tagged for longer laughs | `Oh wow! hahaha [breath] that's hilarious.` |
| **Hesitation** | `[hesitation]` or `[hesitate]` | Unclear thinking noises or mouth sounds while pausing - not specific words | `Well [hesitation] um I'm not really sure.` |
| **Pause** | `[pause]` | Unnaturally long, noticeable pause (e.g., looking something up) | `Let me just uh... [pause] Let me look this up.` |
| **Environment** | `[env]` | Background noise or environmental sounds | `I was thinking [env] about what you said.` |
| **Tut** | `[tut]` | Tongue click or lip smack sound | `[tut] That's not quite right.` |
| **Sigh** | `[sigh]` | Expressive exhale sound | `[sigh] I suppose you're right.` |
| **Sniff** | `[sniff]` | Nasal inhale or sniffing sound | `[sniff] Something smells good in here.` |
| **Cough** | `[cough]` | Coughing sound | `Sorry, excuse me [cough] as I was saying...` |

### Usage Notes

- Non-speech events are placed inline with the transcribed text
- Events appear at their natural position in the conversation flow
- Word elongation is marked with ellipsis: `um... so... I think...`
- Emphasis on words uses asterisks: `I *really* think so`



---

# Speech

# Speech

Hosted **VUI** — expressive, low-latency text-to-speech. Send text, get back audio in a natural voice, with support for non-verbal cues like `[sigh]` and `[laugh]`.

Two ways to render text:

- **HTTP** (`POST /v1/tts`) — one request, one render. Simplest to integrate.
- **WebSocket** (`/v1/tts/ws`) — keep a warm socket open across renders so each one skips the TLS/TCP handshake and reaches first audio sooner. Use this for interactive UIs.

**Pricing:** $9 per 1M characters (≈ $0.48 per hour of audio). See [pricing](/pricing).

## Base URL

Speech is served through the unified Fluxions API gateway under the `/vui` namespace:

```
https://api.fluxions.ai/vui
```

## Authentication

All speech endpoints require your API key in the `Authorization` header — see [Overview](#overview) for API key setup.

Over WebSocket the key rides on the **connection**: send it as the `Authorization` header on the handshake, or as `?token=YOUR_API_KEY` on the URL from a browser.

## GET /voices — List Voices

List the built-in voices.

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/vui/voices" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

voices = requests.get(
    'https://api.fluxions.ai/vui/voices',
    headers={'Authorization': 'YOUR_API_KEY'},
).json()['voices']
for v in voices:
    print(v['voice_id'], '—', v['preview_text'][:50])
```

**JavaScript (.js)**
```javascript
const { voices } = await fetch('https://api.fluxions.ai/vui/voices', {
  headers: { Authorization: 'YOUR_API_KEY' },
}).then(r => r.json());
voices.forEach(v => console.log(v.voice_id, '—', v.preview_text.slice(0, 50)));
```

### Response

```json
{
  "voices": [
    { "voice_id": "maeve.h8ff7e07da", "preview_text": "Hi there. Here's a quick sample of my voice — nice to meet you." },
    { "voice_id": "abraham.h8ff7e07da", "preview_text": "Hi there. Here's a quick sample of my voice — nice to meet you." },
    { "voice_id": "harry.h8ff7e07da", "preview_text": "Hi there. Here's a quick sample of my voice — nice to meet you." }
  ]
}
```

Pass any `voice_id` as the `voice` field when rendering. `preview_text` is what that voice's sample says: `GET /vui/voices/<voice_id>/sample.wav`.

## POST /v1/tts — Render (HTTP)

Synthesize speech from text. Returns a complete WAV by default, or streams audio chunk-by-chunk when `stream=1`.

### Parameters

JSON body:

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `voice` | string | *(required)* | A `voice_id` from `GET /voices` |
| `input` | string | *(required)* | Text to speak. Supports non-verbal cues (see below) |
| `response_format` | string | `"wav"` | `"wav"` (complete file) or `"pcm"` (raw s16le @ 24 kHz) |
| `stream` | boolean | `false` | Stream audio as it's generated instead of buffering the whole file |
| `max_secs` | float | *(auto)* | Hard ceiling on output length. Auto-estimated from text length when omitted |
| `verify_chunks` | boolean | `false` | Re-checks each rendered chunk with a fast speech-to-text pass and re-renders any that misread the text. Improves reliability at the cost of latency; leave it off for the lowest-latency stream (see [Streaming](#streaming)) |

### Request

**Bash (.sh)**
```bash
curl -X POST "https://api.fluxions.ai/vui/v1/tts" \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"voice": "maeve.h8ff7e07da", "input": "[sigh] fine, I will say it one more time."}' \
  --output speech.wav
```

**Python (.py)**
```python
import requests

r = requests.post(
    'https://api.fluxions.ai/vui/v1/tts',
    headers={'Authorization': 'YOUR_API_KEY'},
    json={'voice': 'maeve.h8ff7e07da', 'input': '[sigh] fine, I will say it one more time.'}
)
with open('speech.wav', 'wb') as f:
    f.write(r.content)
```

**JavaScript (.js)**
```javascript
const r = await fetch('https://api.fluxions.ai/vui/v1/tts', {
  method: 'POST',
  headers: { Authorization: 'YOUR_API_KEY', 'Content-Type': 'application/json' },
  body: JSON.stringify({ voice: 'maeve.h8ff7e07da', input: '[sigh] fine, I will say it one more time.' })
});
const wav = await r.blob();
const url = URL.createObjectURL(wav);
new Audio(url).play();
```

### Response

`200 OK` with the audio bytes. `Content-Type` is `audio/wav` (or `audio/L16` when `response_format` is `"pcm"`).

### Streaming

Add `stream=1` (query param or body field) to receive audio as it's generated, delivered as chunked transfer encoding.

By default each chunk streams the instant the model produces it, so first bytes land within ~80 ms. Set `verify_chunks: true` to have each chunk checked — and re-rendered if it misreads the text — *before* it streams: first audio then lands once the first chunk is rendered and verified (~0.5–1 s for a typical sentence), and later chunks are checked while the next one renders.

**Bash (.sh)**
```bash
curl -X POST "https://api.fluxions.ai/vui/v1/tts?stream=1" \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"voice": "maeve.h8ff7e07da", "input": "Streaming starts playing almost immediately."}' \
  --output speech.wav
```

**Python (.py)**
```python
import requests

with requests.post(
    'https://api.fluxions.ai/vui/v1/tts?stream=1',
    headers={'Authorization': 'YOUR_API_KEY'},
    json={'voice': 'maeve.h8ff7e07da', 'input': 'Streaming starts playing almost immediately.'},
    stream=True,
) as r, open('speech.wav', 'wb') as f:
    for chunk in r.iter_content(chunk_size=8192):
        f.write(chunk)
```

**JavaScript (.js)**
```javascript
const r = await fetch('https://api.fluxions.ai/vui/v1/tts?stream=1', {
  method: 'POST',
  headers: { Authorization: 'YOUR_API_KEY', 'Content-Type': 'application/json' },
  body: JSON.stringify({ voice: 'maeve.h8ff7e07da', input: 'Streaming starts playing almost immediately.' }),
});
const reader = r.body.getReader();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  // `value` is a Uint8Array chunk of the streaming WAV — append or play as it arrives
}
```

## WebSocket /v1/tts/ws — Render (warm socket)

Identical render logic to `POST /v1/tts`, but the socket stays open between renders. Hold it open and the TLS/TCP/tunnel handshake is paid once — each subsequent `speak` goes straight to synthesis. Ideal for typing UIs or back-to-back lines.

Audio is delivered as **binary frames of s16le PCM, mono, 24 kHz** (no WAV header — assemble it yourself if you need a file).

### Protocol

**Client → server** (text JSON):

```json
{ "type": "speak", "voice": "<id>", "input": "<text>", "max_secs": 0, "verify_chunks": false }
{ "type": "session.close" }
```

`max_secs` and `verify_chunks` are optional. `verify_chunks` defaults to `false` (lowest latency); set it `true` to check each chunk before it plays (see [Streaming](#streaming)).

**Authentication.** The key is checked once, when the socket opens — send `Authorization: YOUR_API_KEY` on the handshake. Browsers can't set headers on a WebSocket, so from browser JS pass it on the URL instead: `wss://api.fluxions.ai/vui/v1/tts/ws?token=YOUR_API_KEY`. Every `speak` on that socket is then authenticated, including renders with your own cloned voices.

**Server → client:**

| Message | Meaning |
|---------|---------|
| `{"type": "start"}` | The worker stream opened — audio frames follow |
| *(binary frame)* | A chunk of s16le PCM @ 24 kHz |
| `{"type": "done"}` | Current render finished — socket stays open for the next `speak` |
| `{"type": "error", "message": "..."}` | Render failed (socket stays open) |

One render = one `speak` → `start` → binary PCM* → `done`. Send another `speak` on the same socket to render again.

### Request

**JavaScript (.js)**
```javascript
const ws = new WebSocket('wss://api.fluxions.ai/vui/v1/tts/ws?token=YOUR_API_KEY');
ws.binaryType = 'arraybuffer';

const chunks = [];
ws.addEventListener('open', () => {
  ws.send(JSON.stringify({ type: 'speak', voice: 'maeve.h8ff7e07da', input: '[laugh] oh, you are serious?' }));
});
ws.addEventListener('message', (ev) => {
  if (typeof ev.data === 'string') {
    const m = JSON.parse(ev.data);
    if (m.type === 'done') {
      // chunks now hold the full s16le PCM @ 24 kHz — feed to WebAudio or wrap in a WAV
      ws.send(JSON.stringify({ type: 'session.close' }));
    }
    return;
  }
  chunks.push(new Int16Array(ev.data)); // raw PCM frame
});
```

**Python (.py)**
```python
import asyncio, json, websockets

async def render(text, voice='maeve.h8ff7e07da'):
    pcm = bytearray()
    async with websockets.connect(
        'wss://api.fluxions.ai/vui/v1/tts/ws',
        additional_headers={'Authorization': 'YOUR_API_KEY'},
    ) as ws:
        await ws.send(json.dumps({'type': 'speak', 'voice': voice, 'input': text}))
        async for msg in ws:
            if isinstance(msg, bytes):
                pcm += msg                      # s16le PCM @ 24 kHz
            elif json.loads(msg)['type'] == 'done':
                break
    return bytes(pcm)

audio = asyncio.run(render('[sigh] so you want to force me to say things.'))
```

### Conversation context

By default every `speak` renders from the voice alone. For a voice agent, open the socket in **conversation mode** instead: the voice then hears the conversation so far — what the caller said and what it said itself — and replies in context, the way a person carries tone and pacing from one turn to the next.

Send `session.update` as the first message. The voice is fixed for the life of the socket.

```json
{ "type": "session.update", "voice": "<id>", "context": true, "language": "fr" }
```

`language` is optional. The server answers `{"type": "session.updated", "voice": "<id>", "context": true, "caller_audio": true}` — `caller_audio` says whether this server uses caller audio (if `false`, send text only; binary frames are ignored). Then:

| Client → server | Meaning |
|-----------------|---------|
| *(binary frames)* | Optional: the caller's audio, s16le PCM, mono, **16 kHz**, streamed while they speak — it's encoded as it arrives, so the reply doesn't wait for it. Send only the caller's speech (after your VAD), not an open mic. |
| `{"type": "user", "text": "..."}` | Ends the caller's turn: the text (your transcript), plus any audio streamed since the voice's last reply started. Up to the last 10 s of a turn's audio is used. |
| `{"type": "speak", "input": "..."}` | Your reply. Several `speak`s in a row (e.g. one per sentence from your LLM) form one reply until the next `user`. |
| `{"type": "interrupt", "played_ms": 1200}` | Barge-in: stop, and keep only the part of the reply the caller actually heard. `played_ms` counts the reply's audio as played, across its `speak`s. |
| `{"type": "reset"}` | Forget the conversation and start again from the voice. |
| `{"type": "session.close"}` | Close. |

The server replies to each `speak` with `start`, binary PCM and `done`, as above. An `interrupt` is answered with `{"type": "interrupted", "kept_ms": ...}`: what stays in context is cut on a phrase boundary, so `kept_ms` is at most `played_ms`. A `reset` is answered with `{"type": "reset.done"}`. Errors are `{"type": "error", "code": "...", "message": "..."}`. `bad_request` (a malformed message) and `rate_limited` / `insufficient_credits` (that `speak` was not rendered) leave the socket open; `no_slot` means there was no capacity to open the conversation, and the socket closes — retry shortly.

Messages apply in the order you send them: queue a `user` and three `speak`s back to back and they render in that order. `interrupt` and `reset` also drop any `speak` still waiting its turn.

```python
async with websockets.connect('wss://api.fluxions.ai/vui/v1/tts/ws',
                              additional_headers={'Authorization': 'YOUR_API_KEY'}) as ws:
    await ws.send(json.dumps({'type': 'session.update', 'voice': 'maeve.h8ff7e07da', 'context': True}))
    await ws.send(json.dumps({'type': 'user', 'text': "Hi, I'd like to check my balance."}))
    await ws.send(json.dumps({'type': 'speak', 'input': 'Of course, I can help with that.'}))
    await ws.send(json.dumps({'type': 'speak', 'input': 'Could you give me the name on the account?'}))
    # ... read start / PCM / done for each speak, then the next user turn
```

Audio the caller speaks over a reply (barge-in) belongs to their next turn: stream it, send `interrupt`, then `user` when they finish.

**Caller audio or text only?** Text is enough and is the safer default. Sending the caller's audio as well lets the voice react to how something was said, but a caller's voice can occasionally colour the reply.

## Non-Verbal Cues

Wrap a cue in square brackets inside `input` and the model renders it as an expressive sound rather than reading the word aloud:

| Cue | Effect |
|-----|--------|
| `[sigh]` | Audible sigh |
| `[laugh]` | Laughter |
| `[gasp]` | Sharp intake of breath |
| `[sniff]` | Sniffle |
| `[cough]` | Cough |
| `[hesitate]` | Filler / thinking sound |

**Example**: `"[gasp] you did NOT just put pineapple on that pizza! [laugh] okay, okay."`

## Voices

Render with any built-in voice from `GET /voices`, or **clone** a custom voice from a short reference clip. Cloned voices are private to your account — render them exactly like a built-in one, with your API key on the request.

### POST /v1/voices — Clone a Voice

Upload a reference clip plus its transcript; the model encodes a private voice you can render with. Sent as `multipart/form-data`.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `audio` | file | yes | Reference clip (`wav`/`opus`/etc.). A few clean seconds is enough. Max 25 MB |
| `text` | string | no | Exact transcript of the reference clip. **Omit it and we transcribe the clip for you** before cloning |
| `name` | string | no | Display label (defaults to the filename) |

> Leave `text` out and the server runs your clip through transcription automatically — so the simplest clone is just an `audio` file. Pass `text` yourself when you want exact control over the transcript.

**Bash (.sh)**
```bash
curl -X POST "https://api.fluxions.ai/vui/v1/voices" \
  -H "Authorization: YOUR_API_KEY" \
  -F "audio=@reference.wav" \
  -F "text=This is exactly what the reference clip says." \
  -F "name=My Voice"
```

**Python (.py)**
```python
import requests

r = requests.post(
    'https://api.fluxions.ai/vui/v1/voices',
    headers={'Authorization': 'YOUR_API_KEY'},
    data={'text': 'This is exactly what the reference clip says.', 'name': 'My Voice'},
    files={'audio': ('reference.wav', open('reference.wav', 'rb'), 'audio/wav')},
)
voice_id = r.json()['voice_id']
```

**JavaScript (.js)**
```javascript
const fd = new FormData();
fd.append('audio', fileInput.files[0]);
fd.append('text', 'This is exactly what the reference clip says.');
fd.append('name', 'My Voice');

const { voice_id } = await fetch('https://api.fluxions.ai/vui/v1/voices', {
  method: 'POST',
  headers: { Authorization: 'YOUR_API_KEY' },
  body: fd,
}).then(r => r.json());
```

Response: `{ "voice_id": "u-<user>-<hash>", "name": "My Voice", "frames": 173, "seconds": 13.8 }`. Pass the returned `voice_id` as `voice` in any render call (with your token).

### GET /v1/voices/mine — List Your Cloned Voices

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/vui/v1/voices/mine" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

voices = requests.get(
    'https://api.fluxions.ai/vui/v1/voices/mine',
    headers={'Authorization': 'YOUR_API_KEY'},
).json()['voices']
```

**JavaScript (.js)**
```javascript
const { voices } = await fetch('https://api.fluxions.ai/vui/v1/voices/mine', {
  headers: { Authorization: 'YOUR_API_KEY' },
}).then(r => r.json());
```

Returns `{ "voices": [ { "voice_id": "u-...-ab12cd34", "name": "My Voice" } ] }`.

### POST /v1/voices/delete — Remove a Cloned Voice

**Bash (.sh)**
```bash
curl -X POST "https://api.fluxions.ai/vui/v1/voices/delete" \
  -H "Authorization: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"voice_id": "u-...-ab12cd34"}'
```

**Python (.py)**
```python
import requests

requests.post(
    'https://api.fluxions.ai/vui/v1/voices/delete',
    headers={'Authorization': 'YOUR_API_KEY'},
    json={'voice_id': 'u-...-ab12cd34'},
)
```

**JavaScript (.js)**
```javascript
await fetch('https://api.fluxions.ai/vui/v1/voices/delete', {
  method: 'POST',
  headers: { Authorization: 'YOUR_API_KEY', 'Content-Type': 'application/json' },
  body: JSON.stringify({ voice_id: 'u-...-ab12cd34' }),
});
```

> **Note:** cloned voices currently live in the running server's memory, not a database — they're tied to your account but are not guaranteed to survive a server restart. Re-upload if a `voice_id` stops resolving.

## Output Format

- **Sample rate**: 24,000 Hz
- **Channels**: mono
- **Sample format**: signed 16-bit little-endian PCM
- **HTTP `wav`**: PCM wrapped in a standard WAV container
- **HTTP `pcm`** / **WebSocket binary frames**: raw s16le PCM (no header)



---

# History

# History

The History API is one read-only surface over everything you've done on the platform — **transcriptions**, **TTS renders**, and **voice conversations** — under a single host. Use it to list, page, filter, and search your activity, and to fetch download links for the underlying audio and transcripts.

All history endpoints require authentication — see [Overview](#overview) for API key setup.

**Base URL:** `https://api.fluxions.ai`

## One shape for everything

Every **list** response uses the same envelope:

```json
{
  "object": "list",
  "page": 1,
  "limit": 20,
  "total": 137,
  "has_more": true,
  "data": [ /* items */ ]
}
```

Every **item** carries an `object` field telling you its type (`"transcription"`, `"tts"`, or `"conversation"`) plus its native `id`. To fetch one item's detail, call `/history/{type}s/{id}` (e.g. `/history/tts/123`). Timestamps are ISO-8601 UTC; costs are in US dollars.

## Shared query parameters

These work on every collection (and the unified feed):

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `page` | integer | `1` | Page number (1-based) |
| `limit` | integer | `20` | Results per page (max: 100) |
| `order` | string | `desc` | Sort by time: `asc` or `desc` |
| `since` | string | — | Only items at/after this time (ISO-8601 or epoch seconds) |
| `until` | string | — | Only items at/before this time (ISO-8601 or epoch seconds) |

Collection-specific filters: `voice` (tts, conversations), `status` (transcriptions), `type` (the unified feed).

## GET /history — Unified Feed

A merged, reverse-chronological feed across all three types. Filter the streams with `type` (comma-separated).

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `type` | string | *(all)* | Restrict to `transcription`, `tts`, and/or `conversation` (csv) |

*(plus all shared parameters above)*

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/history?limit=10&type=tts,conversation" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

r = requests.get(
    'https://api.fluxions.ai/history',
    headers={'Authorization': 'YOUR_API_KEY'},
    params={'limit': 10, 'type': 'tts,conversation'},
)
for item in r.json()['data']:
    print(item['object'], item['id'], item['created_at'])
```

**JavaScript (.js)**
```javascript
const res = await fetch('https://api.fluxions.ai/history?limit=10&type=tts,conversation', {
  headers: {'Authorization': 'YOUR_API_KEY'}
});
const { data } = await res.json();
data.forEach(i => console.log(i.object, i.id, i.created_at));
```

### Response

```json
{
  "object": "list",
  "page": 1, "limit": 10, "total": 84, "has_more": true,
  "data": [
    { "object": "conversation", "id": "sess_abc", "created_at": "2026-06-29T10:40:00Z",
      "cost_usd": null, "voice": "maeve.en-us", "duration_secs": 312.4, "turn_count": 18 },
    { "object": "tts", "id": 123, "created_at": "2026-06-29T10:32:00Z",
      "cost_usd": 0.0123, "voice": "maeve.en-us", "chars": 842, "audio_secs": 58.4 }
  ]
}
```

> The feed is lightweight: it does **not** include presigned `download_url`s. Use the typed collection or detail endpoints to get them.

## GET /history/transcriptions — Transcription History

List your transcriptions.

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `status` | string | — | Filter by status (e.g. `completed`) |
| `include_download_url` | boolean | `false` | Include a presigned audio URL per item |

*(plus all shared parameters)*

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/history/transcriptions?status=completed&limit=5" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

r = requests.get(
    'https://api.fluxions.ai/history/transcriptions',
    headers={'Authorization': 'YOUR_API_KEY'},
    params={'status': 'completed', 'limit': 5},
)
print(r.json()['total'], 'transcriptions')
```

**JavaScript (.js)**
```javascript
const res = await fetch('https://api.fluxions.ai/history/transcriptions?status=completed&limit=5', {
  headers: {'Authorization': 'YOUR_API_KEY'}
});
console.log((await res.json()).total, 'transcriptions');
```

### Response

```json
{
  "object": "list",
  "page": 1, "limit": 5, "total": 42, "has_more": true,
  "data": [
    {
      "object": "transcription",
      "id": 456,
      "created_at": "2026-06-29T10:35:00Z",
      "cost_usd": 0.10,
      "status": "completed",
      "filename": "interview.mp3",
      "audio_duration_secs": 1800.0,
      "audio_format": "opus",
      "language": "en",
      "num_speakers": 2,
      "num_segments": 142
    }
  ]
}
```

## GET /history/transcriptions/{id} — One Transcription

Returns the full record with presigned `download_url` (audio), `text_url`, and `segments_url`. `404` if it isn't yours.

```bash
curl "https://api.fluxions.ai/history/transcriptions/456" \
  -H "Authorization: YOUR_API_KEY"
```

## GET /history/tts — TTS Render History

List your text-to-speech renders.

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `voice` | string | — | Filter by voice id |
| `include_download_url` | boolean | `true` | Include a presigned Opus URL per item |

*(plus all shared parameters)*

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/history/tts?voice=maeve.en-us&limit=10" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

r = requests.get(
    'https://api.fluxions.ai/history/tts',
    headers={'Authorization': 'YOUR_API_KEY'},
    params={'voice': 'maeve.en-us', 'limit': 10},
)
for render in r.json()['data']:
    print(render['id'], render['chars'], render['download_url'])
```

**JavaScript (.js)**
```javascript
const res = await fetch('https://api.fluxions.ai/history/tts?voice=maeve.en-us&limit=10', {
  headers: {'Authorization': 'YOUR_API_KEY'}
});
const { data } = await res.json();
data.forEach(r => console.log(r.id, r.chars, r.download_url));
```

### Response

```json
{
  "object": "list",
  "page": 1, "limit": 10, "total": 60, "has_more": true,
  "data": [
    {
      "object": "tts",
      "id": 123,
      "created_at": "2026-06-29T10:32:00Z",
      "cost_usd": 0.0123,
      "voice": "maeve.en-us",
      "chars": 842,
      "audio_secs": 58.4,
      "download_url": "https://...r2.cloudflarestorage.com/...opus"
    }
  ]
}
```

## GET /history/tts/{id} — One Render

Returns one render with a fresh signed `download_url`. `404` if it isn't yours.

```bash
curl "https://api.fluxions.ai/history/tts/123" \
  -H "Authorization: YOUR_API_KEY"
```

## GET /history/conversations — Conversation History

List your voice conversations (agent calls).

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `voice` | string | — | Filter by voice id |

*(plus all shared parameters)*

### Request

**Bash (.sh)**
```bash
curl "https://api.fluxions.ai/history/conversations?limit=10" \
  -H "Authorization: YOUR_API_KEY"
```

**Python (.py)**
```python
import requests

r = requests.get(
    'https://api.fluxions.ai/history/conversations',
    headers={'Authorization': 'YOUR_API_KEY'},
    params={'limit': 10},
)
for c in r.json()['data']:
    print(c['id'], c['turn_count'], c['duration_secs'])
```

**JavaScript (.js)**
```javascript
const res = await fetch('https://api.fluxions.ai/history/conversations?limit=10', {
  headers: {'Authorization': 'YOUR_API_KEY'}
});
const { data } = await res.json();
data.forEach(c => console.log(c.id, c.turn_count, c.duration_secs));
```

### Response

```json
{
  "object": "list",
  "page": 1, "limit": 10, "total": 23, "has_more": true,
  "data": [
    {
      "object": "conversation",
      "id": "sess_abc123",
      "created_at": "2026-06-29T10:40:00Z",
      "cost_usd": null,
      "voice": "maeve.en-us",
      "started_at": "2026-06-29T10:40:00Z",
      "ended_at": "2026-06-29T10:45:12Z",
      "duration_secs": 312.4,
      "turn_count": 18
    }
  ]
}
```

## GET /history/conversations/{id} — One Conversation

Returns the session plus its turn-by-turn transcript. `404` if it isn't yours.

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `include_turns` | boolean | `true` | Include the transcript turns |
| `include_tool_calls` | boolean | `false` | Include tool invocations (calendar, email, …) |
| `turns_limit` | integer | `500` | Max turns to return (max: 2000) |

### Request

```bash
curl "https://api.fluxions.ai/history/conversations/sess_abc123?include_tool_calls=true" \
  -H "Authorization: YOUR_API_KEY"
```

### Response

```json
{
  "object": "conversation",
  "id": "sess_abc123",
  "created_at": "2026-06-29T10:40:00Z",
  "voice": "maeve.en-us",
  "duration_secs": 312.4,
  "turn_count": 18,
  "turns": [
    { "object": "conversation_turn", "id": 9001, "session_id": "sess_abc123",
      "role": "user", "text": "What's on my calendar today?", "created_at": "2026-06-29T10:40:05Z" },
    { "object": "conversation_turn", "id": 9002, "session_id": "sess_abc123",
      "role": "assistant", "text": "You have two meetings...", "created_at": "2026-06-29T10:40:08Z" }
  ],
  "tool_calls": [
    { "object": "tool_call", "id": 51, "tool": "calendar",
      "args": {"range": "today"}, "result": "2 events", "created_at": "2026-06-29T10:40:07Z" }
  ]
}
```

## GET /history/conversations/search — Search Turns

Full-text search across your conversation turns (Postgres `websearch_to_tsquery`).

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `q` | string | *(required)* | Search query |
| `limit` | integer | `20` | Max results (max: 100) |

### Request

```bash
curl "https://api.fluxions.ai/history/conversations/search?q=dentist+appointment" \
  -H "Authorization: YOUR_API_KEY"
```

### Response

```json
{
  "object": "list",
  "query": "dentist appointment",
  "data": [
    { "object": "conversation_turn", "id": 9100, "session_id": "sess_def456",
      "role": "user", "text": "remind me about the dentist appointment",
      "created_at": "2026-06-20T14:02:00Z" }
  ]
}
```

## GET /history/search — Cross-Domain Search

Search across your whole history in one call. Conversation turns are matched by full text; transcriptions are matched by filename (their text lives in object storage, not the database). Results are type-tagged via `object`.

### Parameters

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `q` | string | *(required)* | Search query |
| `limit` | integer | `20` | Max results per domain (max: 100) |

### Request

```bash
curl "https://api.fluxions.ai/history/search?q=interview" \
  -H "Authorization: YOUR_API_KEY"
```

### Response

```json
{
  "object": "list",
  "query": "interview",
  "data": [
    { "object": "conversation_turn", "id": 9200, "session_id": "sess_ghi",
      "role": "assistant", "text": "...the interview went well...", "created_at": "2026-06-25T09:00:00Z" },
    { "object": "transcription", "id": 456, "created_at": "2026-06-29T10:35:00Z",
      "status": "completed", "filename": "interview.mp3", "audio_duration_secs": 1800.0 }
  ]
}
```



---

# Errors

# Errors

Every failed request returns a JSON body with a `detail` field and a meaningful HTTP status:

```json
{ "detail": "invalid or expired API key" }
```

Check the status code first — it tells you whether to fix the request, top up, or retry.

| Status | Meaning | Retry? |
|--------|---------|--------|
| `400` | Malformed or incomplete request body | No — fix the request |
| `401` | Missing, invalid, or expired API key | No — fix the key |
| `402` | Out of credits | No — top up at [billing](/billing) |
| `403` | No `Authorization` header on a request that needs one | No — send your key |
| `404` | Unknown endpoint, voice, or job id | No — check the id |
| `409` | That upload was already submitted | No — submit the file again |
| `411` | Upload sent without a `Content-Length` | No — send the file, not a stream |
| `413` | File too large (100 MB upload, 1 GiB by URL) | No — submit it by URL, or split it |
| `422` | Body parsed, but a value failed validation | No — fix the value |
| `429` | Too many requests | Yes — back off and retry |
| `502` / `503` | A model server is restarting or saturated | Yes — retry in a moment |

## 401 — Bad or missing key

```json
{ "detail": "invalid or expired API key" }
{ "detail": "Invalid API key" }
{ "detail": "Authentication credentials not provided" }
```

The key didn't resolve to an account. Check that you're sending the whole key, that it hasn't been revoked, and that the header reached us — some HTTP clients drop `Authorization` when following a redirect.

## 402 — Out of credits

Speech:

```json
{ "detail": "insufficient_credits" }
```

Transcription refuses a submit when the balance is below its $0.01 minimum, and returns the shortfall so you can show it to a user:

```json
{
  "detail": {
    "error": "insufficient_funds",
    "message": "Insufficient funds. Need $0.01, have $0.00",
    "balance_microcents": 400000,
    "required_microcents": 1000000
  }
}
```

Top up at [billing](/billing); the same request then succeeds.

The full charge is made when a transcription starts processing, so a job longer than your balance covers ends `failed`, with the same message in its `error_message`.

To avoid hitting this at all, poll `GET /billing/balance` (see
[Overview](#overview)) and top up when `gated` turns true — the speech 402 above
carries no balance, so it tells you that you have run out but not by how much.

## 403 — No key on the request

```json
{ "detail": "human verification required" }
```

The request arrived with no `Authorization` header at all. Send your API key — see [Overview](#overview).

## 404 — Unknown voice

```json
{ "detail": "render failed: {\"detail\":\"unknown voice: maeve.<old-checkpoint>\"}" }
```

Voice ids are exact and include the serving checkpoint's suffix, which changes when we ship a new model. Don't hard-code one from an old integration — read the current id from `GET /voices` and pass it straight through.

A `404 {"detail": "Not Found"}` with no further text means the path itself doesn't exist: check the service prefix (`/vui`, `/akro`) and the version segment.

## 400 / 422 — Bad request body

```json
{ "detail": "voice and input required" }
```

`400` means a required field was absent; `422` means a field was present but out of range or the wrong type (the body then lists the offending fields). Both are deterministic — retrying without changing the request will fail identically.

## 429 — Rate limited

Back off and retry. Exponential backoff starting around one second is plenty; a burst that trips this will usually clear within a few seconds.

## 502 / 503 — Capacity

```json
{ "detail": "generation unavailable" }
```

A model server is restarting or every worker is busy. These are transient: retry the identical request after a short pause rather than treating it as a bad request.

## WebSocket errors

The speech WebSocket reports failures two ways.

A **render** that fails leaves the socket open and sends an error frame — send another `speak` when you've fixed the input:

```json
{ "type": "error", "code": "insufficient_credits", "message": "insufficient credits" }
```

A **connection** that isn't authenticated is closed immediately with code `1008` (policy violation), before any `speak` is read. The key is checked when the socket opens, so a credential sent inside the first message is too late — put it on the handshake as the `Authorization` header, or on the URL as `?token=`.

## Cold starts

The GPU fleet powers down when idle. The first request after a quiet period is held at the gateway while a machine comes up — expect up to ~30 seconds, then normal latency for subsequent calls. Set your client timeout accordingly; a short timeout will look like a hang or a `502` when the request would have succeeded.


