Documentation
Build with Novax
Two endpoints, one credential. Speech to text and text to speech both authenticate with an API key alone — no session, no cookies — so they work from a server, a script, a notebook, or a cron job.
https://playground.novaxailab.com
Quickstart
Mint a key on your keys page, then send it audio. The audio is the request body — no multipart form, no JSON wrapper.
curl -X POST https://playground.novaxailab.com/api/v1/stt \ -H "Authorization: Bearer $NOVAX_API_KEY" \ -H "Content-Type: audio/wav" \ --data-binary @audio.wav
{
"id": "42",
"text": "kushɛ aw yu de du",
"durationMs": 10000,
"creditsSpent": 2,
"comped": false,
"balance": { "free": 58, "paid": 200, "v2Unlocked": false, "hasFreeGrant": true }
}And the other direction — JSON in, a WAV file out:
curl -X POST https://playground.novaxailab.com/api/v1/tts \
-H "Authorization: Bearer $NOVAX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text": "Kushɛ, aw yu de du?", "voiceId": "krio-fast"}' \
--output speech.wavAuthentication
Send the key as Authorization: Bearer nv_…, or as x-api-key: nv_… if a proxy rewrites authorization headers. It never goes in the body — bodies land in logs and proxy traces far more often than headers do.
- Shown once
- Only a hash of the key is stored, so there's no reveal endpoint. A lost key gets revoked and replaced, not recovered.
- It is the identity
- Whoever holds the key spends the owner's credits, and every run appears in the owner's history. Keep it server-side, in an environment variable, out of client code and out of git.
- Getting one
- Creating a key needs a balance — claim the free credits or buy some first. An existing key keeps working when that balance runs down, because cutting off a key mid-project is a worse failure than refusing a new one. Ten live keys per account.
Unknown, revoked and malformed keys all answer 401 identically. A caller shouldn't be able to probe which of the three it's holding.
Speech to text
POST /api/v1/stt — one request, one clip. The bytes travel in the body rather than through a URL we fetch, so a leaked key can spend credits but never reach into stored media.
- Audio formats
audio/wav,audio/mpeg,audio/flac,audio/ogg. Send it as the request body, with the format inContent-Type.- Limits
- 4 MB per request, and up to 224s of audio in one call. Split longer recordings — there's a splitter below — or use the browser playground, which walks a two-hour file automatically.
- Query params
?model=v1orauto(default), and?title=to label the run in your history.- Duration
- Read from the WAV header, not taken from the caller — it's what credits are priced on. Other formats can't be measured without decoding, so they bill as a single segment. Send 16 kHz mono WAV where you can.
- Billing
- Krio v1 costs 1 credit per 5 seconds of audio, rounded up and capped at 500 an hour (900for two), taken from the key owner's balance. It takes audio from 5 seconds up and won't start unless that balance is at least 12. Failed runs are refunded — nobody pays for a transcript they didn't get.
- No v2
- v2 isn't reachable from the API — its one-time unlock is a purchase, and that belongs in front of a person, not a machine call. It also takes audio from 3 minutes up, which no single API call can carry anyway.
Text to speech
POST /api/v1/tts— same key, same header. The response body is the audio itself, and the clip is saved to your account, so it's replayable from the playground later.
{
"text": "Kushɛ, aw yu de du?", // required, max 2,000 characters
"voiceId": "krio-fast" // required — see GET /api/voices
}- Voices
krio-fast(v1, lowest latency) andkrio-natural(v2, more natural). List them withGET /api/voices, which needs no key. The English voice-cloning voices are listed there but aren't part of this endpoint's contract.- Response
- The audio —
audio/wavbytes, not JSON.X-Latency-Ms,X-VoiceandX-Clip-Idcome back in the headers; the clip id addresses the generation in your history afterwards. - Limits
- 2,000 characters per request. Generation can take up to two minutes on a cold model, so set your client timeout accordingly.
- Billing
- Free while text to speech is in preview — no credits are taken. That will change before it leaves preview.
Next.js
Start from a fresh app, or skip to the client if you already have one.
Create a project
pnpm create next-app@latest krio-app --yes cd krio-app && pnpm dev
--yes takes the defaults — TypeScript, ESLint, Tailwind, App Router, Turbopack and the @/* import alias. Node 20.9 or newer. The full options are in the Next.js installation guide.
Keep the key on the server
NOVAX_API_KEY=nv_your_key_here NOVAX_API_URL=https://playground.novaxailab.com
Never name it NEXT_PUBLIC_NOVAX_API_KEY. That prefix ships the value to every visitor's browser, and this key spends your credits.
A typed client
One module that owns the key, the error shape and the timeouts. import "server-only" turns "don't import this from a client component" into a build error rather than a leaked key.
import "server-only";
const BASE = process.env.NOVAX_API_URL ?? "https://playground.novaxailab.com";
function key(): string {
const k = process.env.NOVAX_API_KEY;
if (!k) throw new Error("NOVAX_API_KEY is not set");
return k;
}
export type Transcription = {
id: string;
text: string;
durationMs: number | null;
creditsSpent: number;
comped: boolean;
balance: { free: number; paid: number; v2Unlocked: boolean; hasFreeGrant: boolean };
};
/** Thrown for any non-2xx, carrying the `code` you branch on. */
export class NovaxError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
}
}
export async function transcribe(
audio: ArrayBuffer,
{ mime = "audio/wav", title }: { mime?: string; title?: string } = {},
): Promise<Transcription> {
const url = new URL("/api/v1/stt", BASE);
if (title) url.searchParams.set("title", title);
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${key()}`, "Content-Type": mime },
body: audio,
// A transcription is a mutation and costs money — never serve a cached one.
cache: "no-store",
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
throw new NovaxError(body.error ?? "Transcription failed", res.status, body.code);
}
return (await res.json()) as Transcription;
}
export async function synthesize(
text: string,
voiceId: "krio-fast" | "krio-natural" = "krio-fast",
): Promise<{ audio: ArrayBuffer; contentType: string; clipId: string | null }> {
const res = await fetch(new URL("/api/v1/tts", BASE), {
method: "POST",
headers: { Authorization: `Bearer ${key()}`, "Content-Type": "application/json" },
body: JSON.stringify({ text, voiceId }),
cache: "no-store",
// Cold models take their time; don't let a default timeout cut it short.
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string; code?: string };
throw new NovaxError(body.error ?? "Generation failed", res.status, body.code);
}
return {
audio: await res.arrayBuffer(),
contentType: res.headers.get("content-type") ?? "audio/wav",
clipId: res.headers.get("x-clip-id"),
};
}Route handler: upload in, transcript out
import { NovaxError, transcribe } from "@/lib/novax";
export const runtime = "nodejs";
export const maxDuration = 300; // transcription outlives a default invocation
const ALLOWED = new Set(["audio/wav", "audio/mpeg", "audio/flac", "audio/ogg"]);
const MAX_BYTES = 4 * 1024 * 1024;
export async function POST(request: Request) {
// Authenticate your own users here: a route handler is a public POST
// endpoint, and this one spends your credits.
const mime = (request.headers.get("content-type") ?? "").split(";")[0].trim();
if (!ALLOWED.has(mime)) {
return Response.json({ error: "Send WAV, MP3, FLAC or OGG audio." }, { status: 415 });
}
const audio = await request.arrayBuffer();
if (audio.byteLength > MAX_BYTES) {
return Response.json({ error: "Audio is over 4 MB." }, { status: 413 });
}
try {
const result = await transcribe(audio, { mime, title: "Web upload" });
return Response.json({ text: result.text, id: result.id });
} catch (e) {
if (e instanceof NovaxError) {
// Don't forward the upstream body wholesale — `balance` is your
// financial state, not your uploader's business. An empty balance is
// your outage, so it answers 503 rather than the caller's 402.
const status = e.code === "insufficient-credits" ? 503 : e.status;
return Response.json({ error: e.message, code: e.code }, { status });
}
console.error("[/api/transcribe]", e);
return Response.json({ error: "Transcription failed." }, { status: 500 });
}
}Route handlers aren't cached by default, so a POST needs no dynamic export.
Calling it from a client component
"use client";
import { useState } from "react";
export function UploadForm() {
const [text, setText] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function onFile(file: File) {
setBusy(true);
setError("");
try {
const res = await fetch("/api/transcribe", {
method: "POST",
headers: { "Content-Type": file.type || "audio/wav" },
body: file, // raw bytes, matching what the handler reads
});
const body = await res.json();
if (!res.ok) throw new Error(body.error ?? "Upload failed");
setText(body.text);
} catch (e) {
setError(e instanceof Error ? e.message : "Upload failed");
} finally {
setBusy(false);
}
}
return (
<div>
<input
type="file"
accept="audio/wav,audio/mpeg,audio/flac,audio/ogg"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void onFile(file);
}}
/>
{busy && <p>Transcribing…</p>}
{error && <p role="alert">{error}</p>}
{text && <p>{text}</p>}
</div>
);
}Or a Server Action
Fine for a form post, with one catch: Server Action bodies are capped at 1 MB by default — below this API's own 4 MB ceiling. Raise it, leaving 10–20 KB of headroom for multipart overhead.
"use server";
import { transcribe } from "@/lib/novax";
export async function transcribeAction(formData: FormData) {
// Server Actions are reachable by direct POST, not only through your UI.
// Authenticate here, every time.
const file = formData.get("audio");
if (!(file instanceof File)) return { error: "No audio uploaded." };
const result = await transcribe(await file.arrayBuffer(), {
mime: file.type || "audio/wav",
title: file.name,
});
return { text: result.text };
}import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: { bodySizeLimit: "5mb" },
},
};
export default nextConfig;Playing speech back
import { synthesize } from "@/lib/novax";
export const runtime = "nodejs";
export const maxDuration = 120;
export async function POST(request: Request) {
const { text } = (await request.json()) as { text?: string };
if (!text?.trim()) return Response.json({ error: "text is required" }, { status: 400 });
const { audio, contentType, clipId } = await synthesize(text.slice(0, 2000));
return new Response(audio, {
headers: {
"Content-Type": contentType,
"Cache-Control": "no-store",
...(clipId ? { "X-Clip-Id": clipId } : {}),
},
});
}In the browser that's an object URL on an audio element:
const res = await fetch("/api/speak", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
setSrc(URL.createObjectURL(await res.blob()));Deploying
- Body size
- Vercel rejects request bodies over 4.5 MB before your handler runs — which is why this API caps at 4 MB. Reject early yourself, with a message a user can act on.
- Timeouts
- Set
maxDuration. Transcription can take minutes, and default function timeouts are far shorter. - Keys per env
- Separate keys for preview and production, so revoking a leaked preview key doesn't take production down with it.
Python
pip install requests
A client
"""Minimal client for the Novax Krio speech API."""
import os
from dataclasses import dataclass
import requests
BASE = os.environ.get("NOVAX_API_URL", "https://playground.novaxailab.com")
class NovaxError(RuntimeError):
"""Non-2xx from the API. `code` is the machine-readable reason."""
def __init__(self, message: str, status: int, code: str | None = None):
super().__init__(message)
self.status = status
self.code = code
@dataclass
class Transcription:
id: str
text: str
duration_ms: int | None
credits_spent: int
def _key() -> str:
key = os.environ.get("NOVAX_API_KEY")
if not key:
raise RuntimeError("NOVAX_API_KEY is not set")
return key
def _raise(res: requests.Response) -> None:
try:
body = res.json()
except ValueError:
body = {}
raise NovaxError(body.get("error", res.text[:200]), res.status_code, body.get("code"))
def transcribe(path: str, *, mime: str = "audio/wav", title: str | None = None) -> Transcription:
"""Transcribe one clip — up to 4 MB and 224 s of audio."""
with open(path, "rb") as fh:
audio = fh.read()
res = requests.post(
f"{BASE}/api/v1/stt",
headers={"Authorization": f"Bearer {_key()}", "Content-Type": mime},
params={"title": title} if title else None,
data=audio, # raw bytes: not files=, not json=
timeout=(10, 300), # connect, read — the model is the slow part
)
if not res.ok:
_raise(res)
body = res.json()
return Transcription(
id=body["id"],
text=body["text"],
duration_ms=body.get("durationMs"),
credits_spent=body["creditsSpent"],
)
def synthesize(text: str, voice_id: str = "krio-fast", out: str = "speech.wav") -> str:
"""Write synthesized Krio speech to `out` and return the path."""
res = requests.post(
f"{BASE}/api/v1/tts",
headers={"Authorization": f"Bearer {_key()}", "Content-Type": "application/json"},
json={"text": text[:2000], "voiceId": voice_id},
timeout=(10, 150), # cold models can take two minutes
)
if not res.ok:
_raise(res)
with open(out, "wb") as fh:
fh.write(res.content) # the body is WAV bytes, not JSON
return outfrom novax import transcribe, synthesize
print(transcribe("meeting.wav", title="Standup").text)
synthesize("Kushɛ, aw yu de du?", out="greeting.wav")Long audio
One call takes at most 224 s, and the 4 MB body cap bites sooner at high sample rates. wave splits a WAV with no audio dependencies at all:
import wave
from pathlib import Path
MAX_SECONDS = 200 # under the 224s ceiling, with room to spare
def split_wav(path: str, out_dir: str = "chunks") -> list[str]:
"""Split a WAV into <=MAX_SECONDS pieces, preserving the format."""
Path(out_dir).mkdir(exist_ok=True)
paths: list[str] = []
with wave.open(path, "rb") as src:
rate = src.getframerate()
per_chunk = rate * MAX_SECONDS
index = 0
while True:
frames = src.readframes(per_chunk)
if not frames:
break
out = f"{out_dir}/chunk_{index:03d}.wav"
with wave.open(out, "wb") as dst:
dst.setnchannels(src.getnchannels())
dst.setsampwidth(src.getsampwidth())
dst.setframerate(rate)
dst.writeframes(frames)
paths.append(out)
index += 1
return paths
def transcribe_long(path: str) -> str:
from novax import transcribe
# Sequential on purpose: the chunks share one credit balance, and firing
# them in parallel turns "out of credits" into a transcript with a hole in
# the middle rather than a clean stop.
return " ".join(transcribe(chunk).text for chunk in split_wav(path))Cutting on a fixed clock splits words at the boundaries. If that matters, cut on silence instead — pydub.silence.split_on_silence or ffmpeg -af silencedetect — and keep every piece under the ceiling.
Converting anything else into the format the pricing is measured on:
ffmpeg -i input.m4a -ac 1 -ar 16000 -c:a pcm_s16le output.wav
Retries
Only some failures are worth repeating. A 502 is usually a cold or slow model, and a failed run is refunded, so retrying doesn't double-charge. A 401 or 402 will never succeed on a retry.
import random
import time
from novax import NovaxError, transcribe
RETRYABLE = {502, 503, 504}
def transcribe_with_retry(path: str, attempts: int = 3):
for attempt in range(attempts):
try:
return transcribe(path)
except NovaxError as e:
if e.status not in RETRYABLE or attempt == attempts - 1:
raise # 401/402/413/415 are verdicts, not hiccups
time.sleep((2**attempt) + random.random())Serving it: FastAPI
The mirror of the Next.js route handler — your users talk to you, only you hold the key.
from fastapi import FastAPI, HTTPException, Request, Response
from novax import NovaxError, synthesize, transcribe
app = FastAPI()
ALLOWED = {"audio/wav", "audio/mpeg", "audio/flac", "audio/ogg"}
MAX_BYTES = 4 * 1024 * 1024
@app.post("/transcribe")
async def transcribe_endpoint(request: Request):
mime = request.headers.get("content-type", "").split(";")[0].strip()
if mime not in ALLOWED:
raise HTTPException(415, "Send WAV, MP3, FLAC or OGG audio.")
audio = await request.body()
if len(audio) > MAX_BYTES:
raise HTTPException(413, "Audio is over 4 MB.")
tmp = "/tmp/upload.wav"
with open(tmp, "wb") as fh:
fh.write(audio)
try:
result = transcribe(tmp, mime=mime)
except NovaxError as e:
# An empty balance is your outage, not the caller's bad request.
status = 503 if e.code == "insufficient-credits" else e.status
raise HTTPException(status, str(e)) from e
return {"id": result.id, "text": result.text}
@app.post("/speak")
def speak(body: dict):
path = synthesize(body.get("text", ""), out="/tmp/speech.wav")
with open(path, "rb") as fh:
return Response(fh.read(), media_type="audio/wav")Blocking requests calls inside async def stall the event loop. Either declare the endpoint def, as /speak does, and let FastAPI run it in a threadpool — or move to httpx.AsyncClient.
Errors
Every failure is JSON carrying an error you can show and, where it's worth branching on, a code you can switch on.
| Status | Code | Meaning | Retry |
|---|---|---|---|
| 401 | no-key | No Authorization or x-api-key header | No |
| 401 | bad-key | Unknown, revoked or malformed key | No |
| 402 | insufficient-credits | Balance won't cover the run | No — top up |
| 413 | too-large | Body over 4 MB | No — split |
| 413 | too-long | Over 224s of audio | No — split |
| 415 | bad-format | Content-Type isn't a supported audio type | No |
| 400 | bad-voice | Unknown voice, or one this endpoint won't serve | No |
| 502 | transcribe-failed | Model failed — credits are refunded | Yes |
| 502/504 | — | Voice model failed or timed out | Yes |
A 402 also carries cost and balance, so a job runner can log exactly how short it was. Don't forward that to your own end users — it's the key owner's financial state.
Still to come
More of the platform lands on the developer portal first. Questions in the meantime? Talk to us.