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.
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
| Response | Retry? | Why |
|---|---|---|
429 | Yes, honouring Retry-After | You went over the per-second quota, or you have too many requests in flight. |
502 503 504 | Yes, with growing backoff | A node failed. The gatekeeper already retries once on its own before handing it back to you. |
400 401 402 403 404 413 415 422 | No | The 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…"}
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
| Header | When it appears | What it says |
|---|---|---|
X-Request-Id | always | Identifies the request. It is the first thing we will ask you for if something goes wrong. |
X-Audio-Duration | audio in or generated | Seconds being billed. It is not in the body, only here. |
X-RateLimit-ServiceX-RateLimit-Limit-SecondX-RateLimit-Remaining-Second | always | Per-second quota for the service you used, and what is left of it. |
X-Credits-Limit-MonthlyX-Credits-Used-MonthlyX-Credits-Remaining-MonthlyX-Credits-Reset-Monthly | only if your plan has a pool | State of this cycle's credits. |
X-Credits-Overage | once past the pool | true: you are still being served, but into billable overage. |
X-Concurrency-LimitX-Concurrency-ActiveX-Concurrency-Slots | only if your plan has a cap | Simultaneous requests allowed, in flight, and how many this one takes. |
X-Cache | speech | HIT · MISS · BYPASS · ADHOC · DISABLED. A hit costs a tenth; BYPASS confirms that nothing was stored. |
Retry-After | 429 and 402 | Seconds you have to wait. Let it decide, not your own counter. |
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.
// 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.
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);
}
}
}