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.wav

Authentication

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 in Content-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=v1 or auto (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) and krio-natural (v2, more natural). List them with GET /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/wav bytes, not JSON. X-Latency-Ms, X-Voice and X-Clip-Id come 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

.env.local
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.

lib/novax.ts
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

app/api/transcribe/route.ts
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

app/upload-form.tsx
"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.

app/actions.ts
"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 };
}
next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  experimental: {
    serverActions: { bodySizeLimit: "5mb" },
  },
};

export default nextConfig;

Playing speech back

app/api/speak/route.ts
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

novax.py
"""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 out
from 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:

split.py
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.

retry.py
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.

main.py
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.

StatusCodeMeaningRetry
401no-keyNo Authorization or x-api-key headerNo
401bad-keyUnknown, revoked or malformed keyNo
402insufficient-creditsBalance won't cover the runNo — top up
413too-largeBody over 4 MBNo — split
413too-longOver 224s of audioNo — split
415bad-formatContent-Type isn't a supported audio typeNo
400bad-voiceUnknown voice, or one this endpoint won't serveNo
502transcribe-failedModel failed — credits are refundedYes
502/504Voice model failed or timed outYes

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.