UtteraUttera

Putting Uttera in your code

There is no SDK. The API is plain HTTP and multipart/form-data, so you can talk to it from any language without installing anything of ours. Below there is a complete client —upload, retries, reading what was billed— in four languages.

All four examples are run against the live API before being published here. What you read is the same file that ran, not a reconstruction.

What you should know first

Timeouts: the rule is two hours

The server keeps the connection open for up to 7200 seconds. A client with the default timeout —30 s in many libraries— cuts off jobs that were going perfectly. It is the most common mistake when integrating.

For reference, measured: a 100-minute recording is transcribed in 10 to 35 seconds depending on which node handles it. The long wait is not for the normal case, it is so the rare case is not lost.

What to retry and what not to

ResponseRetry?Why
429Yes, honouring Retry-AfterYou went over the per-second quota, or you have too many requests in flight.
502 503 504Yes, with growing backoffA node failed. The gatekeeper already retries once on its own before handing it back to you.
400 401 402 403 404 413 415 422NoThe problem is in the request or in the account. Retrying changes nothing and burns quota.

Errors raised by the edge all arrive in the same shape:

{"error": "quota_exceeded", "message": "Monthly credit balance depleted…"}
There is one exception, and it is worth handling. When the error comes from the engine and not from the edge —a 400 for a file that cannot be decoded, or one over the maximum size— the response carries detail instead of error and message:
{"detail": "Maximum file size exceeded (parameter=audio_filesize_mb, value=112.9)"}
When reading an error, look at error and fall back to detail if it is not there.

Headers every response carries

HeaderWhen it appearsWhat it says
X-Request-IdalwaysIdentifies the request. It is the first thing we will ask you for if something goes wrong.
X-Audio-Durationaudio in or generatedSeconds being billed. It is not in the body, only here.
X-RateLimit-Service
X-RateLimit-Limit-Second
X-RateLimit-Remaining-Second
alwaysPer-second quota for the service you used, and what is left of it.
X-Credits-Limit-Monthly
X-Credits-Used-Monthly
X-Credits-Remaining-Monthly
X-Credits-Reset-Monthly
only if your plan has a poolState of this cycle's credits.
X-Credits-Overageonce past the pooltrue: you are still being served, but into billable overage.
X-Concurrency-Limit
X-Concurrency-Active
X-Concurrency-Slots
only if your plan has a capSimultaneous requests allowed, in flight, and how many this one takes.
X-CachespeechHIT · MISS · BYPASS · ADHOC · DISABLED. A hit costs a tenth; BYPASS confirms that nothing was stored.
Retry-After429 and 402Seconds you have to wait. Let it decide, not your own counter.
A header that describes a limit only appears if there is a limit. On enterprise, with an uncapped pool and uncapped concurrency, you will see none of the credit or concurrency headers. That is not a fault: there is nothing to count.

One endpoint moves several services

/v1/translate and /v1/summarize chain several engines together, and each stage takes a concurrency slot. On a plan with 3 simultaneous requests, two summaries at once can give you 429 too_many_concurrent_requests even though you only launched two requests. Look at X-Concurrency-Slots to know how many slots each one spends.

Normalize the audio before sending it

The engine resamples everything to 16 kHz mono anyway. If you upload a 48 kHz stereo WAV you are paying bandwidth and upload time for information that is going to be thrown away, and you reach the size cap sooner.

ffmpeg -i original.wav -ar 16000 -ac 1 -b:a 64k ready.mp3

Two hours of audio like that weighs about 58 MB, well under the limit. The same recording as 48 kHz stereo WAV would be 1.3 GB.

Bash

For one-off jobs, cron and pipelines. It only needs curl.

#!/usr/bin/env bash
# Transcribe a recording, with retries and a read of what was billed.
set -euo pipefail

: "${UTTERA_API_KEY:?export your key: export UTTERA_API_KEY=sk-echo-...}"
API=https://api.uttera.ai
FILE=${1:?usage: ./transcribe.sh recording.mp3}

# The rule is two hours: the server holds the connection for up to 7200 s, so
# the client must not give up sooner or you will cut off a job that was fine.
MAX_WAIT=7200

attempt() {
  curl -sS --max-time "$MAX_WAIT" \
       -D /tmp/uttera.hdr -o /tmp/uttera.out -w '%{http_code}' \
       -H "Authorization: Bearer $UTTERA_API_KEY" \
       -F "file=@${FILE}" \
       -F "model=whisper-1" \
       -F "response_format=text" \
       "$API/v1/audio/transcriptions"
}

for try in 1 2 3 4 5; do
  code=$(attempt) || code=000

  # 2xx: done.
  if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
    cat /tmp/uttera.out
    echo
    echo "--- seconds billed: $(grep -i '^x-audio-duration:' /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2)"
    echo "--- request id:     $(grep -i '^x-request-id:'     /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2)"
    exit 0
  fi

  # 4xx (except 429): the problem is yours. Retrying will not fix it.
  if [ "$code" -ge 400 ] && [ "$code" -lt 500 ] && [ "$code" != 429 ]; then
    echo "error $code, not retrying:" >&2
    cat /tmp/uttera.out >&2
    exit 1
  fi

  # 429 and 5xx: temporary. If Retry-After comes back, let it decide.
  wait=$(grep -i '^retry-after:' /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2 || true)
  [ -n "${wait:-}" ] || wait=$(( 2 ** try ))
  echo "error $code, attempt $try, retrying in ${wait}s" >&2
  sleep "$wait"
done

echo "retries exhausted" >&2
exit 1

Python

It only needs requests. It includes the query for what you were charged, which is asked for separately because the charge is computed after you have been answered.

"""Minimal Uttera client. Only needs `requests`."""
import os
import time

import requests

API = "https://api.uttera.ai"
# The rule is two hours: the server holds on for up to 7200 s. The first number
# is the connect timeout, the second the read timeout between response bytes.
TIMEOUT = (10, 7200)
# 429 asks you to wait; 502/503/504 are a node that failed and is already
# retried once on the server. Other 4xx are yours: retrying will not fix them.
RETRYABLE = {429, 502, 503, 504}


class UtteraError(RuntimeError):
    def __init__(self, status, body, request_id=None):
        self.status = status
        self.code = (body or {}).get("error")
        self.request_id = request_id
        super().__init__(f"{status} {self.code}: {(body or {}).get('message')}")


class Uttera:
    def __init__(self, key=None, api=API, attempts=4):
        self.key = key or os.environ["UTTERA_API_KEY"]
        self.api = api
        self.attempts = attempts
        self.session = requests.Session()

    def _call(self, method, path, **kw):
        headers = {"Authorization": f"Bearer {self.key}"}
        headers.update(kw.pop("headers", {}))
        for attempt in range(1, self.attempts + 1):
            r = self.session.request(method, self.api + path,
                                     headers=headers, timeout=TIMEOUT, **kw)
            if r.ok:
                return r
            if r.status_code not in RETRYABLE or attempt == self.attempts:
                try:
                    body = r.json()
                except ValueError:
                    body = {"message": r.text[:200]}
                raise UtteraError(r.status_code, body, r.headers.get("X-Request-Id"))
            # If the server says how long to wait, it decides.
            wait = float(r.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)

    def transcribe(self, audio_path, **fields):
        fields.setdefault("model", "whisper-1")
        with open(audio_path, "rb") as f:
            r = self._call("POST", "/v1/audio/transcriptions",
                           files={"file": (os.path.basename(audio_path), f)},
                           data=fields)
        return r

    def speak(self, text, voice="alloy", fmt="mp3"):
        return self._call("POST", "/v1/audio/speech",
                          json={"model": "tts-1", "voice": voice,
                                "input": text, "response_format": fmt})

    def last_charge(self, endpoint=None):
        params = {"endpoint": endpoint} if endpoint else None
        return self._call("GET", "/v1/usage/last", params=params).json()


if __name__ == "__main__":
    import sys

    u = Uttera()
    r = u.transcribe(sys.argv[1], response_format="text")
    print(r.text.strip())
    # The seconds you are billed for come in the header, not in the body.
    print("seconds billed:", r.headers.get("X-Audio-Duration"))
    print("request id:    ", r.headers.get("X-Request-Id"))
    # The charge is computed AFTER you are answered, so it is queried apart.
    print("charged:", u.last_charge()["credits"], "credits")

JavaScript · Node

No dependencies: fetch, FormData and Blob are built into Node from version 20. The file is an ES module, so either name it .mjs or put "type": "module" in your package.json.

Never put the key in code that runs in the browser. Anyone can open the developer tools and walk off with it, and with your credit pool. From a web page you call your backend, and your backend is what talks to us. This example is for the server.
// Minimal Uttera client. No dependencies: fetch, FormData and Blob are built
// into Node from version 20.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";

const API = "https://api.uttera.ai";
// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours:
// retrying will not fix them.
const RETRYABLE = new Set([429, 502, 503, 504]);

export class UtteraError extends Error {
  constructor(status, body, requestId) {
    super(`${status} ${body?.error}: ${body?.message}`);
    this.name = "UtteraError";
    this.status = status;
    this.code = body?.error;
    this.requestId = requestId;
  }
}

const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

export class Uttera {
  constructor(key = process.env.UTTERA_API_KEY, { api = API, attempts = 4 } = {}) {
    if (!key) throw new Error("UTTERA_API_KEY is missing");
    this.key = key;
    this.api = api;
    this.attempts = attempts;
  }

  async #call(path, options = {}, rebuild = null) {
    for (let attempt = 1; attempt <= this.attempts; attempt++) {
      // The body is consumed when sent: if we have to retry, it is rebuilt.
      const body = rebuild ? await rebuild() : options.body;
      // The rule is two hours: the server holds on for up to 7200 s. Without
      // this, fetch waits forever; with 30 s you would cut off healthy jobs.
      const res = await fetch(this.api + path, {
        ...options,
        body,
        headers: { Authorization: `Bearer ${this.key}`, ...options.headers },
        signal: AbortSignal.timeout(7200_000),
      });

      if (res.ok) return res;
      if (!RETRYABLE.has(res.status) || attempt === this.attempts) {
        let detail = null;
        try { detail = await res.json(); } catch { detail = { message: await res.text() }; }
        throw new UtteraError(res.status, detail, res.headers.get("x-request-id"));
      }
      // If the server says how long to wait, it decides.
      await sleep(Number(res.headers.get("retry-after")) || 2 ** attempt);
    }
  }

  async transcribe(path, fields = {}) {
    const rebuild = async () => {
      const fd = new FormData();
      fd.append("file", await openAsBlob(path), basename(path));
      fd.append("model", fields.model ?? "whisper-1");
      for (const [k, v] of Object.entries(fields)) if (k !== "model") fd.append(k, v);
      return fd;
    };
    return this.#call("/v1/audio/transcriptions", { method: "POST" }, rebuild);
  }

  async speak(text, voice = "alloy", fmt = "mp3") {
    return this.#call("/v1/audio/speech", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: "tts-1", voice, input: text, response_format: fmt }),
    });
  }

  async lastCharge(endpoint) {
    const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
    return (await this.#call(`/v1/usage/last${q}`)).json();
  }
}

// ── usage ──────────────────────────────────────────────────────────────────
const u = new Uttera();
const res = await u.transcribe(process.argv[2], { response_format: "text" });
console.log((await res.text()).trim());
// The seconds you are billed for come in the header, not in the body.
console.log("seconds billed:", res.headers.get("x-audio-duration"));
console.log("request id:    ", res.headers.get("x-request-id"));
// The charge is computed AFTER you are answered, so it is queried apart.
console.log("charged:", (await u.lastCharge()).credits, "credits");

TypeScript

The same client with types for the responses and the errors, which is what really adds value here: code is stable and you can branch on it, message is for a human to read.

It runs with node uttera.ts, with no compilation step. In exchange you have to stay inside the erasable subset of the language: no enum, no namespace and no parameter properties (constructor(private x: T)), because those emit code and not just types. With erasableSyntaxOnly in your tsconfig.json, the compiler warns you if you step outside.
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["ES2023", "DOM"],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"]
  }
}
// Minimal Uttera client in TypeScript. No dependencies.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";

const API = "https://api.uttera.ai";
const RETRYABLE = new Set([429, 502, 503, 504]);

/** Shape of EVERY API error. Always the same, wherever it is handled. */
export interface ErrorBody {
  error: string;
  message: string;
  /** Only on errors raised by the edge (404, 413, 502…). */
  status?: number;
  docs?: string;
}

/** What GET /v1/usage/last returns. The breakdown depends on the endpoint. */
export interface Charge {
  endpoint: string;
  service: string;
  credits: number;
  breakdown: Record<string, number>;
  ts: number;
  audio_seconds?: number;
  input_tokens?: number;
  output_tokens?: number;
}

export type ResponseFormat = "json" | "text" | "verbose_json" | "srt" | "vtt";

export interface TranscribeOptions {
  model?: string;
  language?: string;
  prompt?: string;
  response_format?: ResponseFormat;
  temperature?: number;
}

export class UtteraError extends Error {
  readonly status: number;
  readonly code: string;
  readonly requestId: string | null;

  constructor(status: number, body: Partial<ErrorBody>, requestId: string | null) {
    super(`${status} ${body.error}: ${body.message}`);
    this.name = "UtteraError";
    this.status = status;
    this.code = body.error ?? "unknown";
    this.requestId = requestId;
  }
}

const sleep = (s: number): Promise<void> =>
  new Promise((r) => setTimeout(r, s * 1000));

export class Uttera {
  // ⚠ Fields declared and assigned by hand, NOT parameter properties
  // (`constructor(private key: string)`). That form emits code, not just
  // types, and Node refuses to run it directly: with ordinary fields this file
  // runs as it is with `node uttera.ts`, no compilation step.
  private readonly key: string;
  private readonly attempts: number;
  private readonly api: string;

  constructor(
    key: string = process.env.UTTERA_API_KEY ?? "",
    attempts: number = 4,
    api: string = API,
  ) {
    if (!key) throw new Error("UTTERA_API_KEY is missing");
    this.key = key;
    this.attempts = attempts;
    this.api = api;
  }

  private async call(
    path: string,
    options: RequestInit = {},
    rebuild?: () => Promise<BodyInit>,
  ): Promise<Response> {
    for (let attempt = 1; attempt <= this.attempts; attempt++) {
      // The body is consumed when sent: if we have to retry, it is rebuilt.
      const body = rebuild ? await rebuild() : options.body;
      // The rule is two hours: the server holds on for up to 7200 s.
      const res = await fetch(this.api + path, {
        ...options,
        body,
        headers: { Authorization: `Bearer ${this.key}`, ...options.headers },
        signal: AbortSignal.timeout(7_200_000),
      });

      if (res.ok) return res;
      if (!RETRYABLE.has(res.status) || attempt === this.attempts) {
        const err = (await res.json().catch(() => ({}))) as Partial<ErrorBody>;
        throw new UtteraError(res.status, err, res.headers.get("x-request-id"));
      }
      // If the server says how long to wait, it decides.
      await sleep(Number(res.headers.get("retry-after")) || 2 ** attempt);
    }
    throw new Error("unreachable");
  }

  async transcribe(path: string, options: TranscribeOptions = {}): Promise<Response> {
    const rebuild = async (): Promise<FormData> => {
      const fd = new FormData();
      fd.append("file", await openAsBlob(path), basename(path));
      fd.append("model", options.model ?? "whisper-1");
      for (const [k, v] of Object.entries(options)) {
        if (k !== "model" && v !== undefined) fd.append(k, String(v));
      }
      return fd;
    };
    return this.call("/v1/audio/transcriptions", { method: "POST" }, rebuild);
  }

  async lastCharge(endpoint?: string): Promise<Charge> {
    const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
    return (await this.call(`/v1/usage/last${q}`)).json() as Promise<Charge>;
  }
}

// ── usage ──────────────────────────────────────────────────────────────────
const u = new Uttera();
try {
  const res = await u.transcribe(process.argv[2]!, { response_format: "text" });
  console.log((await res.text()).trim());
  console.log("seconds billed:", res.headers.get("x-audio-duration"));
  const charge = await u.lastCharge();
  console.log("charged:", charge.credits, "credits", charge.breakdown);
} catch (e) {
  if (e instanceof UtteraError) {
    // `code` is stable; `message` is for a human to read, not to branch on.
    console.error(`failed ${e.code} (request ${e.requestId})`);
    process.exit(1);
  }
  throw e;
}

Go

No dependencies: everything is standard library. Watch out for one detail that bites: the multipart body is consumed when it is sent, so it has to be rebuilt on every retry rather than reusing the same reader.

// Minimal Uttera client. No dependencies: standard library only.
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"mime/multipart"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"time"
)

const api = "https://api.uttera.ai"

// The rule is two hours: the server holds on for up to 7200 s. A Client with
// no Timeout waits forever; one with 30 s cuts off healthy jobs.
var client = &http.Client{Timeout: 2 * time.Hour}

// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours.
func retryable(code int) bool {
	return code == 429 || code == 502 || code == 503 || code == 504
}

type APIError struct {
	Status    int    `json:"-"`
	Code      string `json:"error"`
	Message   string `json:"message"`
	RequestID string `json:"-"`
}

func (e *APIError) Error() string {
	return fmt.Sprintf("%d %s: %s (request %s)", e.Status, e.Code, e.Message, e.RequestID)
}

// multipart must be rebuilt on every attempt: the body is consumed when sent.
func audioBody(path string, fields map[string]string) (io.Reader, string, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, "", err
	}
	defer f.Close()

	var buf bytes.Buffer
	w := multipart.NewWriter(&buf)
	part, err := w.CreateFormFile("file", filepath.Base(path))
	if err != nil {
		return nil, "", err
	}
	if _, err := io.Copy(part, f); err != nil {
		return nil, "", err
	}
	for k, v := range fields {
		if err := w.WriteField(k, v); err != nil {
			return nil, "", err
		}
	}
	if err := w.Close(); err != nil {
		return nil, "", err
	}
	return &buf, w.FormDataContentType(), nil
}

func Transcribe(key, path string, fields map[string]string) (string, http.Header, error) {
	for attempt := 1; attempt <= 4; attempt++ {
		body, ctype, err := audioBody(path, fields)
		if err != nil {
			return "", nil, err
		}
		req, err := http.NewRequest("POST", api+"/v1/audio/transcriptions", body)
		if err != nil {
			return "", nil, err
		}
		req.Header.Set("Authorization", "Bearer "+key)
		req.Header.Set("Content-Type", ctype)

		res, err := client.Do(req)
		if err != nil {
			return "", nil, err
		}
		data, _ := io.ReadAll(res.Body)
		res.Body.Close()

		if res.StatusCode < 300 {
			return string(data), res.Header, nil
		}
		if !retryable(res.StatusCode) || attempt == 4 {
			e := &APIError{Status: res.StatusCode, RequestID: res.Header.Get("X-Request-Id")}
			if json.Unmarshal(data, e) != nil {
				e.Message = string(data)
			}
			return "", res.Header, e
		}
		// If the server says how long to wait, it decides.
		wait := math.Pow(2, float64(attempt))
		if ra, err := strconv.ParseFloat(res.Header.Get("Retry-After"), 64); err == nil {
			wait = ra
		}
		time.Sleep(time.Duration(wait * float64(time.Second)))
	}
	return "", nil, fmt.Errorf("retries exhausted")
}

func main() {
	key := os.Getenv("UTTERA_API_KEY")
	if key == "" || len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: UTTERA_API_KEY=sk-echo-... ./uttera recording.mp3")
		os.Exit(2)
	}
	text, hdr, err := Transcribe(key, os.Args[1], map[string]string{
		"model":           "whisper-1",
		"response_format": "text",
	})
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println(text)
	// The seconds you are billed for come in the header, not in the body.
	fmt.Println("seconds billed:", hdr.Get("X-Audio-Duration"))
	fmt.Println("request id:    ", hdr.Get("X-Request-Id"))
}

Rust

With reqwest in blocking mode, which for an API client reads far better than the async one. It uses rustls instead of native-tls so as not to depend on the system's OpenSSL.

[package]
name = "uttera"
version = "0.1.0"
edition = "2021"

[dependencies]
# rustls instead of native-tls: that way you do not need the system OpenSSL.
reqwest = { version = "0.12", default-features = false, features = [
    "blocking", "multipart", "json", "rustls-tls", "charset", "http2",
] }
serde_json = "1"
//! Minimal Uttera client.
use std::{env, thread, time::Duration};

const API: &str = "https://api.uttera.ai";

/// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours:
/// retrying will not fix them.
fn retryable(code: u16) -> bool {
    matches!(code, 429 | 502 | 503 | 504)
}

fn transcribe(key: &str, path: &str) -> Result<(String, reqwest::header::HeaderMap), String> {
    // The rule is two hours: the server holds on for up to 7200 s. A short
    // timeout cuts off healthy jobs.
    let client = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(7200))
        .build()
        .map_err(|e| e.to_string())?;

    for attempt in 1..=4u32 {
        // The form is consumed when sent: it has to be rebuilt every time.
        let form = reqwest::blocking::multipart::Form::new()
            .file("file", path)
            .map_err(|e| e.to_string())?
            .text("model", "whisper-1")
            .text("response_format", "text");

        let res = client
            .post(format!("{API}/v1/audio/transcriptions"))
            .bearer_auth(key)
            .multipart(form)
            .send()
            .map_err(|e| e.to_string())?;

        let code = res.status().as_u16();
        let headers = res.headers().clone();
        let body = res.text().unwrap_or_default();

        if (200..300).contains(&code) {
            return Ok((body, headers));
        }
        if !retryable(code) || attempt == 4 {
            let detail = serde_json::from_str::<serde_json::Value>(&body)
                .ok()
                .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
                .unwrap_or(body);
            let id = headers
                .get("x-request-id")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("-");
            return Err(format!("{code}: {detail} (request {id})"));
        }
        // If the server says how long to wait, it decides.
        let wait = headers
            .get("retry-after")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse::<u64>().ok())
            .unwrap_or(1u64 << attempt);
        thread::sleep(Duration::from_secs(wait));
    }
    Err("retries exhausted".into())
}

fn main() {
    let key = env::var("UTTERA_API_KEY").expect("export UTTERA_API_KEY");
    let path = env::args().nth(1).expect("usage: uttera recording.mp3");

    match transcribe(&key, &path) {
        Ok((text, hdr)) => {
            println!("{}", text.trim());
            // The seconds you are billed for come in the header, not the body.
            let read = |n| hdr.get(n).and_then(|v| v.to_str().ok()).unwrap_or("-");
            println!("seconds billed: {}", read("x-audio-duration"));
            println!("request id:     {}", read("x-request-id"));
        }
        Err(e) => {
            eprintln!("{e}");
            std::process::exit(1);
        }
    }
}