Uttera

Documentación

Uttera es una API de voz. Le mandas audio y te devuelve texto, análisis o más audio; le mandas texto y te devuelve voz. Todo por HTTP, con una clave, sin SDK obligatorio.

Está pensada para lo que pasa después de una llamada de teléfono: transcribirla, saber quién habló y en qué tono, traducirla o resumirla. No es un traductor de texto ni un lector de documentos: el audio está en un extremo o en el otro, siempre.

Transcribir

Audio → texto, con detección automática de idioma.

Convertir texto en voz

Texto → audio, con catálogo de voces.

Traducir

Audio en un idioma → texto y voz en otro.

Analizar la voz

Tono, perfil del hablante, quién habla y cuándo.

Resumir

Una llamada entera → resumen estructurado.

Conceptos

Créditos

Todo se cobra en créditos. Un crédito equivale a un segundo de GPU, y cada servicio tiene su coeficiente según lo que consume de verdad. No hay tarifas por petición ni mínimos: pagas por segundo de audio procesado o generado.

Tu plan te da una bolsa mensual de créditos que se renueva en la fecha de tu suscripción, no el día 1 del mes. Lo que no gastas no se acumula.

Voces

Hay dos familias. Las voces estándar son un catálogo fijo, rápidas y baratas. La voz clonada reproduce un timbre concreto a partir de una muestra, cuesta unas 19 veces más por segundo y no está en todos los planes.

Duración, no tamaño

El cobro va por segundos de audio, no por megabytes. Un mismo minuto de voz cuesta lo mismo en WAV que en MP3, aunque el fichero pese veinte veces más.

Tu primera llamada

Necesitas una clave. La creas en la consola; empieza por sk-echo- y se muestra una sola vez.

curl -X POST https://api.uttera.ai/v1/audio/transcriptions \
  -H "Authorization: Bearer $UTTERA_API_KEY" \
  -F file=@llamada.mp3 \
  -F model=whisper-1 \
  -F response_format=text

Respuesta:

Buenos días, llamaba por el pedido de la semana pasada...
Consejo: si prefieres no escribir código todavía, la página Probar de la consola ejecuta los cinco servicios desde el navegador y te enseña lo que cuesta cada uno antes y después de lanzarlo.

Transcribir

POST /v1/audio/transcriptions

Convierte una grabación en texto. Detecta el idioma solo.

ParámetroTipoDescripción
fileficheroObligatorio. El audio.
modeltextowhisper-1
languagetextoCódigo ISO. Si se omite, se detecta.
prompttextoContexto para ayudar con nombres propios o jerga.
response_formattextojson · text · verbose_json · srt · vtt
temperaturenúmero0 a 1. Por defecto 0.
extrasconsulta?extras=sentiment añade el análisis de tono en la misma llamada.

La respuesta lleva la cabecera X-Audio-Duration con los segundos exactos que se han facturado.

Convertir texto en voz

POST /v1/audio/speech

ParámetroTipoDescripción
inputtextoObligatorio. Lo que hay que decir.
modeltextotts-1 voz estándar · tts-1-hd voz clonada
voicetextoNombre del catálogo. Ver voces.
response_formattextomp3 · wav · flac · ogg · opus
speednúmeroVelocidad. 1.0 es la normal.
languagetextoIdioma de lectura. Importa: sin él, un texto en inglés puede leerse con fonética española.
curl -X POST https://api.uttera.ai/v1/audio/speech \
  -H "Authorization: Bearer $UTTERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"tts-1","input":"Su pedido sale mañana.","voice":"nova","response_format":"mp3"}' \
  --output voz.mp3
Caché: el mismo texto con la misma voz se sirve de caché y cuesta el 10 %. La respuesta lo indica en la cabecera X-Cache: HIT.

Traducir

POST /v1/translate

Cadena completa: transcribe el audio, traduce el texto y lo sintetiza en el idioma destino. Admite entrada de audio o de texto, pero no texto a texto: si entra texto, la salida tiene que incluir audio.

ParámetroTipoDescripción
fileficheroEl audio de origen.
targetconsultaObligatorio. Idioma destino.
sourceconsultaIdioma de origen. Por defecto se detecta.
responseconsultaboth texto y audio · text solo texto · audio solo audio
voiceconsultaVoz de salida, del catálogo estándar.
curl -X POST "https://api.uttera.ai/v1/translate?target=en&response=both" \
  -H "Authorization: Bearer $UTTERA_API_KEY" \
  -F file=@llamada.mp3

Devuelve source_text, text traducido y audio en base64 con su audio_format.

Idiomas con voz: se traduce a ~50 idiomas, pero solo nueve tienen voz. Si pides audio en uno que no la tiene, la petición se rechaza con 422 antes de gastar nada y te devuelve la lista válida. Usa response=text para el resto.

Analizar la voz

Tres endpoints sobre el mismo audio, cada uno con su precio.

EndpointQué hace
POST /v1/audio/sentimentTono emocional de la grabación.
POST /v1/audio/profilePerfil del hablante: rango de edad y género estimados.
POST /v1/audio/diarizeQuién habla y cuándo, con marcas de tiempo por interlocutor.
curl -X POST https://api.uttera.ai/v1/audio/diarize \
  -H "Authorization: Bearer $UTTERA_API_KEY" \
  -F file=@llamada.mp3
El perfil es una estimación acústica, no un dato del hablante. Se basa en características de la voz y se equivoca. No lo uses para nada que tenga consecuencias para una persona.

Resumir una llamada

POST /v1/summarize Professional y superiores

Transcribe, analiza el tono, perfila al hablante, separa interlocutores y con todo eso genera un resumen estructurado. Los cuatro servicios de origen corren en paralelo, así que el tiempo de espera es el del más lento, no la suma.

ParámetroTipoDescripción
fileficheroObligatorio. La grabación.
excludeconsultaDesactiva enriquecimientos: ?exclude=emotion,profile,diarize
languageconsultaIdioma del resumen. Por defecto, español.

Devuelve summary, transcript, enrichment y un bloque usage con los créditos desglosados por tramo.

Un fallo parcial no tumba el resumen. Si un enriquecimiento falla, la respuesta sale igual con un array warnings, y ese tramo no se cobra.

Integrar Uttera en tu código

No hay SDK. La API es HTTP y multipart/form-data corriente, así que se habla con ella desde cualquier lenguaje sin instalar nada nuestro. Abajo hay un cliente completo —subida, reintentos, lectura de lo facturado— en cuatro lenguajes.

Los cuatro ejemplos se ejecutan contra la API real antes de publicarse aquí. Lo que lees es el mismo fichero que corrió, no una reconstrucción.

Lo que conviene saber antes

Tiempos de espera: la regla es dos horas

El servidor mantiene la conexión abierta hasta 7200 segundos. Un cliente con el tiempo de espera por defecto —30 s en muchas librerías— corta trabajos que iban perfectamente. Es el fallo más común al integrar.

Como referencia medida: una grabación de 100 minutos se transcribe en 10 a 35 segundos según el nodo que la atienda. La espera larga no es para el caso normal, es para que el caso raro no se pierda.

Qué se reintenta y qué no

Respuesta¿Reintentar?Por qué
429, respetando Retry-AfterHas pasado el cupo por segundo, o tienes demasiadas peticiones a la vez.
502 503 504, con espera crecienteUn nodo falló. El gatekeeper ya reintenta una vez por su cuenta antes de devolvértelo.
400 401 402 403 404 413 415 422NoEl problema está en la petición o en la cuenta. Reintentar da lo mismo y gasta cupo.

Los errores que genera el borde llegan todos con la misma forma:

{"error": "quota_exceeded", "message": "Monthly credit balance depleted…"}
Hay una excepción, y conviene tenerla prevista. Cuando el error lo produce el motor y no el borde —un 400 por un fichero que no se puede decodificar, o que pasa del tamaño máximo— la respuesta trae detail en vez de error y message:
{"detail": "Maximum file size exceeded (parameter=audio_filesize_mb, value=112.9)"}
Al leer un error, mira error y cae a detail si no está.

Cabeceras que trae cada respuesta

CabeceraCuándo apareceQué dice
X-Request-IdsiempreIdentifica la petición. Es lo primero que te vamos a pedir si algo va mal.
X-Audio-Durationaudio de entrada o generadoSegundos que se facturan. No está en el cuerpo, solo aquí.
X-RateLimit-Service
X-RateLimit-Limit-Second
X-RateLimit-Remaining-Second
siempreCupo por segundo del servicio que has usado, y lo que te queda.
X-Credits-Limit-Monthly
X-Credits-Used-Monthly
X-Credits-Remaining-Monthly
X-Credits-Reset-Monthly
solo si tu plan tiene bolsaEstado de los créditos del ciclo.
X-Credits-Overageal pasarte de la bolsatrue: sigues servido, pero en exceso facturable.
X-Concurrency-Limit
X-Concurrency-Active
X-Concurrency-Slots
solo si tu plan tiene topePeticiones simultáneas permitidas, en curso, y cuántas consume esta.
X-CachevozHIT si el audio ya estaba generado. Un acierto cuesta una décima parte.
Retry-After429 y 402Segundos que hay que esperar. Mándalo él, no tu propia cuenta.
Una cabecera que describe un límite solo aparece si hay límite. En enterprise, con bolsa y concurrencia sin tope, no verás ninguna de las de créditos ni de concurrencia. No es un fallo: no hay nada que contar.

Un endpoint mueve varios servicios

/v1/translate y /v1/summarize encadenan varios motores, y cada tramo consume un hueco de concurrencia. Con un plan de 3 simultáneas, dos resúmenes a la vez pueden darte 429 too_many_concurrent_requests aunque solo hayas lanzado dos peticiones. Mira X-Concurrency-Slots para saber cuántos huecos gasta cada una.

Normaliza el audio antes de enviarlo

El motor remuestrea todo a 16 kHz mono de todas formas. Si subes un WAV de 48 kHz estéreo estás pagando ancho de banda y tiempo de subida por información que se va a tirar, y te acercas antes al tope de tamaño.

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

Dos horas de audio así pesan unos 58 MB, muy por debajo del límite. La misma grabación en WAV de 48 kHz estéreo serían 1,3 GB.

Bash

Para trabajos de una vez, cron y tuberías. Solo necesita curl.

#!/usr/bin/env bash
# Transcribir una grabación, con reintentos y lectura de lo facturado.
set -euo pipefail

: "${UTTERA_API_KEY:?exporta tu clave: export UTTERA_API_KEY=sk-echo-...}"
API=https://api.uttera.ai
FICHERO=${1:?uso: ./transcribir.sh grabacion.mp3}

# La regla es dos horas: el servidor mantiene la conexión hasta 7200 s, así que
# el cliente no debe rendirse antes o cortarás un trabajo que iba bien.
MAX_ESPERA=7200

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

for intento in 1 2 3 4 5; do
  codigo=$(intentar) || codigo=000

  # 2xx: hecho.
  if [ "$codigo" -ge 200 ] && [ "$codigo" -lt 300 ]; then
    cat /tmp/uttera.out
    echo
    echo "--- segundos facturados: $(grep -i '^x-audio-duration:' /tmp/uttera.cab | tr -d '\r' | cut -d' ' -f2)"
    echo "--- id de petición:      $(grep -i '^x-request-id:'     /tmp/uttera.cab | tr -d '\r' | cut -d' ' -f2)"
    exit 0
  fi

  # 4xx (menos 429): el problema es tuyo. Reintentar no lo arregla.
  if [ "$codigo" -ge 400 ] && [ "$codigo" -lt 500 ] && [ "$codigo" != 429 ]; then
    echo "error $codigo, no se reintenta:" >&2
    cat /tmp/uttera.out >&2
    exit 1
  fi

  # 429 y 5xx: temporal. Si viene Retry-After, mándalo él.
  espera=$(grep -i '^retry-after:' /tmp/uttera.cab | tr -d '\r' | cut -d' ' -f2 || true)
  [ -n "${espera:-}" ] || espera=$(( 2 ** intento ))
  echo "error $codigo, intento $intento, reintento en ${espera}s" >&2
  sleep "$espera"
done

echo "agotados los reintentos" >&2
exit 1

Python

Solo necesita requests. Incluye la consulta de lo cobrado, que se pide aparte porque el cargo se calcula después de responderte.

"""Cliente mínimo de Uttera. Solo necesita `requests`."""
import os
import time

import requests

API = "https://api.uttera.ai"
# La regla es dos horas: el servidor aguanta hasta 7200 s. El primer número es
# el tiempo para conectar, el segundo el de espera entre bytes de respuesta.
ESPERA = (10, 7200)
# 429 pide que esperes; 502/503/504 son un nodo que falló y se reintenta solo
# una vez en el servidor. El resto de 4xx son tuyos: reintentar no los arregla.
REINTENTABLES = {429, 502, 503, 504}


class ErrorUttera(RuntimeError):
    def __init__(self, estado, cuerpo, request_id=None):
        self.estado = estado
        self.codigo = (cuerpo or {}).get("error")
        self.request_id = request_id
        super().__init__(f"{estado} {self.codigo}: {(cuerpo or {}).get('message')}")


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

    def _llamar(self, metodo, ruta, **kw):
        cabeceras = {"Authorization": f"Bearer {self.clave}"}
        cabeceras.update(kw.pop("headers", {}))
        for intento in range(1, self.intentos + 1):
            r = self.sesion.request(metodo, self.api + ruta,
                                    headers=cabeceras, timeout=ESPERA, **kw)
            if r.ok:
                return r
            if r.status_code not in REINTENTABLES or intento == self.intentos:
                try:
                    cuerpo = r.json()
                except ValueError:
                    cuerpo = {"message": r.text[:200]}
                raise ErrorUttera(r.status_code, cuerpo, r.headers.get("X-Request-Id"))
            # Si el servidor dice cuánto esperar, manda él.
            espera = float(r.headers.get("Retry-After", 2 ** intento))
            time.sleep(espera)

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

    def sintetizar(self, texto, voz="alloy", formato="mp3"):
        return self._llamar("POST", "/v1/audio/speech",
                            json={"model": "tts-1", "voice": voz,
                                  "input": texto, "response_format": formato})

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


if __name__ == "__main__":
    import sys

    u = Uttera()
    r = u.transcribir(sys.argv[1], response_format="text")
    print(r.text.strip())
    # Los segundos que se facturan vienen en la cabecera, no en el cuerpo.
    print("segundos facturados:", r.headers.get("X-Audio-Duration"))
    print("id de petición:     ", r.headers.get("X-Request-Id"))
    # El cargo se calcula DESPUÉS de responderte, así que se consulta aparte.
    print("cobrado:", u.ultimo_cargo()["credits"], "créditos")

JavaScript · Node

Sin dependencias: fetch, FormData y Blob son nativos en Node desde la 20. El fichero es un módulo ES, así que o lo llamas .mjs o pones "type": "module" en tu package.json.

Nunca pongas la clave en código que se ejecute en el navegador. Cualquiera abre las herramientas de desarrollo y se la lleva, y con ella tu bolsa de créditos. Desde una página web se llama a tu backend, y es tu backend el que habla con nosotros. Este ejemplo es para servidor.
// Cliente mínimo de Uttera. Sin dependencias: fetch, FormData y Blob son
// nativos en Node desde la 20.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";

const API = "https://api.uttera.ai";
// 429 pide que esperes; 5xx es un nodo que falló. El resto de 4xx son tuyos:
// reintentar no los arregla.
const REINTENTABLES = new Set([429, 502, 503, 504]);

export class ErrorUttera extends Error {
  constructor(estado, cuerpo, requestId) {
    super(`${estado} ${cuerpo?.error}: ${cuerpo?.message}`);
    this.name = "ErrorUttera";
    this.estado = estado;
    this.codigo = cuerpo?.error;
    this.requestId = requestId;
  }
}

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

export class Uttera {
  constructor(clave = process.env.UTTERA_API_KEY, { api = API, intentos = 4 } = {}) {
    if (!clave) throw new Error("falta UTTERA_API_KEY");
    this.clave = clave;
    this.api = api;
    this.intentos = intentos;
  }

  async #llamar(ruta, opciones = {}, rearmar = null) {
    for (let intento = 1; intento <= this.intentos; intento++) {
      // El cuerpo se consume al enviarlo: si hay que reintentar, se rearma.
      const cuerpo = rearmar ? await rearmar() : opciones.body;
      // La regla es dos horas: el servidor aguanta hasta 7200 s. Sin esto,
      // fetch espera para siempre; con 30 s cortarías trabajos que iban bien.
      const res = await fetch(this.api + ruta, {
        ...opciones,
        body: cuerpo,
        headers: { Authorization: `Bearer ${this.clave}`, ...opciones.headers },
        signal: AbortSignal.timeout(7200_000),
      });

      if (res.ok) return res;
      if (!REINTENTABLES.has(res.status) || intento === this.intentos) {
        let detalle = null;
        try { detalle = await res.json(); } catch { detalle = { message: await res.text() }; }
        throw new ErrorUttera(res.status, detalle, res.headers.get("x-request-id"));
      }
      // Si el servidor dice cuánto esperar, manda él.
      await dormir(Number(res.headers.get("retry-after")) || 2 ** intento);
    }
  }

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

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

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

// ── uso ────────────────────────────────────────────────────────────────────
const u = new Uttera();
const res = await u.transcribir(process.argv[2], { response_format: "text" });
console.log((await res.text()).trim());
// Los segundos facturados vienen en la cabecera, no en el cuerpo.
console.log("segundos facturados:", res.headers.get("x-audio-duration"));
console.log("id de petición:     ", res.headers.get("x-request-id"));
// El cargo se calcula DESPUÉS de responderte, así que se consulta aparte.
console.log("cobrado:", (await u.ultimoCargo()).credits, "créditos");

TypeScript

El mismo cliente con los tipos de las respuestas y de los errores, que es lo que de verdad aporta aquí: codigo es estable y se puede ramificar sobre él, message es para que lo lea un humano.

Se ejecuta con node uttera.ts, sin paso de compilación. A cambio hay que quedarse en el subconjunto borrable del lenguaje: nada de enum, namespace ni «parameter properties» (constructor(private x: T)), porque eso emite código y no solo tipos. Con erasableSyntaxOnly en el tsconfig.json, el compilador te avisa si te sales.
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["ES2023", "DOM"],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"]
  }
}
// Cliente mínimo de Uttera en TypeScript. Sin dependencias.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";

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

/** Forma de TODOS los errores de la API. Siempre la misma, se traten donde se traten. */
export interface CuerpoError {
  error: string;
  message: string;
  /** Solo en los errores que genera el borde (404, 413, 502…). */
  status?: number;
  docs?: string;
}

/** Lo que devuelve GET /v1/usage/last. El desglose depende del endpoint. */
export interface Cargo {
  endpoint: string;
  service: string;
  credits: number;
  breakdown: Record<string, number>;
  ts: number;
  audio_seconds?: number;
  input_tokens?: number;
  output_tokens?: number;
}

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

export interface OpcionesTranscribir {
  model?: string;
  language?: string;
  prompt?: string;
  response_format?: FormatoRespuesta;
  temperature?: number;
}

export class ErrorUttera extends Error {
  readonly estado: number;
  readonly codigo: string;
  readonly requestId: string | null;

  constructor(estado: number, cuerpo: Partial<CuerpoError>, requestId: string | null) {
    super(`${estado} ${cuerpo.error}: ${cuerpo.message}`);
    this.name = "ErrorUttera";
    this.estado = estado;
    this.codigo = cuerpo.error ?? "unknown";
    this.requestId = requestId;
  }
}

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

export class Uttera {
  // ⚠ Campos declarados y asignados a mano, NO «parameter properties»
  // (`constructor(private clave: string)`). Esa forma emite código, no solo
  // tipos, y Node se niega a ejecutarla directamente: con campos normales este
  // fichero corre tal cual con `node uttera.ts`, sin paso de compilación.
  private readonly clave: string;
  private readonly intentos: number;
  private readonly api: string;

  constructor(
    clave: string = process.env.UTTERA_API_KEY ?? "",
    intentos: number = 4,
    api: string = API,
  ) {
    if (!clave) throw new Error("falta UTTERA_API_KEY");
    this.clave = clave;
    this.intentos = intentos;
    this.api = api;
  }

  private async llamar(
    ruta: string,
    opciones: RequestInit = {},
    rearmar?: () => Promise<BodyInit>,
  ): Promise<Response> {
    for (let intento = 1; intento <= this.intentos; intento++) {
      // El cuerpo se consume al enviarlo: si hay que reintentar, se rearma.
      const body = rearmar ? await rearmar() : opciones.body;
      // La regla es dos horas: el servidor aguanta hasta 7200 s.
      const res = await fetch(this.api + ruta, {
        ...opciones,
        body,
        headers: { Authorization: `Bearer ${this.clave}`, ...opciones.headers },
        signal: AbortSignal.timeout(7_200_000),
      });

      if (res.ok) return res;
      if (!REINTENTABLES.has(res.status) || intento === this.intentos) {
        const cuerpo = (await res.json().catch(() => ({}))) as Partial<CuerpoError>;
        throw new ErrorUttera(res.status, cuerpo, res.headers.get("x-request-id"));
      }
      // Si el servidor dice cuánto esperar, manda él.
      await dormir(Number(res.headers.get("retry-after")) || 2 ** intento);
    }
    throw new Error("inalcanzable");
  }

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

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

// ── uso ────────────────────────────────────────────────────────────────────
const u = new Uttera();
try {
  const res = await u.transcribir(process.argv[2]!, { response_format: "text" });
  console.log((await res.text()).trim());
  console.log("segundos facturados:", res.headers.get("x-audio-duration"));
  const cargo = await u.ultimoCargo();
  console.log("cobrado:", cargo.credits, "créditos", cargo.breakdown);
} catch (e) {
  if (e instanceof ErrorUttera) {
    // `codigo` es estable; `message` es para leerlo un humano, no para ramificar.
    console.error(`fallo ${e.codigo} (request ${e.requestId})`);
    process.exit(1);
  }
  throw e;
}

Go

Sin dependencias: todo es biblioteca estándar. Ojo a un detalle que muerde: el cuerpo multipart se consume al enviarlo, así que hay que rearmarlo en cada reintento, no reutilizar el mismo lector.

// Cliente mínimo de Uttera. Sin dependencias: solo biblioteca estándar.
package main

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

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

// La regla es dos horas: el servidor aguanta hasta 7200 s. Un Client sin
// Timeout espera para siempre; uno con 30 s corta trabajos que iban bien.
var cliente = &http.Client{Timeout: 2 * time.Hour}

// 429 pide que esperes; 5xx es un nodo que falló. El resto de 4xx son tuyos.
func reintentable(codigo int) bool {
	return codigo == 429 || codigo == 502 || codigo == 503 || codigo == 504
}

type ErrorAPI struct {
	Estado    int    `json:"-"`
	Codigo    string `json:"error"`
	Mensaje   string `json:"message"`
	RequestID string `json:"-"`
}

func (e *ErrorAPI) Error() string {
	return fmt.Sprintf("%d %s: %s (request %s)", e.Estado, e.Codigo, e.Mensaje, e.RequestID)
}

// multipart hay que rearmarlo en cada intento: el cuerpo se consume al enviarlo.
func cuerpoAudio(ruta string, campos map[string]string) (io.Reader, string, error) {
	f, err := os.Open(ruta)
	if err != nil {
		return nil, "", err
	}
	defer f.Close()

	var buf bytes.Buffer
	w := multipart.NewWriter(&buf)
	parte, err := w.CreateFormFile("file", filepath.Base(ruta))
	if err != nil {
		return nil, "", err
	}
	if _, err := io.Copy(parte, f); err != nil {
		return nil, "", err
	}
	for k, v := range campos {
		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 Transcribir(clave, ruta string, campos map[string]string) (string, http.Header, error) {
	for intento := 1; intento <= 4; intento++ {
		cuerpo, tipo, err := cuerpoAudio(ruta, campos)
		if err != nil {
			return "", nil, err
		}
		req, err := http.NewRequest("POST", api+"/v1/audio/transcriptions", cuerpo)
		if err != nil {
			return "", nil, err
		}
		req.Header.Set("Authorization", "Bearer "+clave)
		req.Header.Set("Content-Type", tipo)

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

		if res.StatusCode < 300 {
			return string(datos), res.Header, nil
		}
		if !reintentable(res.StatusCode) || intento == 4 {
			e := &ErrorAPI{Estado: res.StatusCode, RequestID: res.Header.Get("X-Request-Id")}
			if json.Unmarshal(datos, e) != nil {
				e.Mensaje = string(datos)
			}
			return "", res.Header, e
		}
		// Si el servidor dice cuánto esperar, manda él.
		espera := math.Pow(2, float64(intento))
		if ra, err := strconv.ParseFloat(res.Header.Get("Retry-After"), 64); err == nil {
			espera = ra
		}
		time.Sleep(time.Duration(espera * float64(time.Second)))
	}
	return "", nil, fmt.Errorf("agotados los reintentos")
}

func main() {
	clave := os.Getenv("UTTERA_API_KEY")
	if clave == "" || len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "uso: UTTERA_API_KEY=sk-echo-... ./uttera grabacion.mp3")
		os.Exit(2)
	}
	texto, cab, err := Transcribir(clave, 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(texto)
	// Los segundos facturados vienen en la cabecera, no en el cuerpo.
	fmt.Println("segundos facturados:", cab.Get("X-Audio-Duration"))
	fmt.Println("id de petición:     ", cab.Get("X-Request-Id"))
}

Rust

Con reqwest en modo bloqueante, que para un cliente de API se lee mucho mejor que el asíncrono. Se usa rustls en vez de native-tls para no depender del OpenSSL del sistema.

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

[dependencies]
# rustls en vez de native-tls: así no hace falta tener OpenSSL del sistema.
reqwest = { version = "0.12", default-features = false, features = [
    "blocking", "multipart", "json", "rustls-tls", "charset", "http2",
] }
serde_json = "1"
//! Cliente mínimo de Uttera.
use std::{env, thread, time::Duration};

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

/// 429 pide que esperes; 5xx es un nodo que falló. El resto de 4xx son tuyos:
/// reintentar no los arregla.
fn reintentable(codigo: u16) -> bool {
    matches!(codigo, 429 | 502 | 503 | 504)
}

fn transcribir(clave: &str, ruta: &str) -> Result<(String, reqwest::header::HeaderMap), String> {
    // La regla es dos horas: el servidor aguanta hasta 7200 s. Un timeout corto
    // corta trabajos que iban bien.
    let cliente = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(7200))
        .build()
        .map_err(|e| e.to_string())?;

    for intento in 1..=4u32 {
        // El formulario se consume al enviarlo: hay que rearmarlo cada vez.
        let formulario = reqwest::blocking::multipart::Form::new()
            .file("file", ruta)
            .map_err(|e| e.to_string())?
            .text("model", "whisper-1")
            .text("response_format", "text");

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

        let codigo = res.status().as_u16();
        let cabeceras = res.headers().clone();
        let cuerpo = res.text().unwrap_or_default();

        if (200..300).contains(&codigo) {
            return Ok((cuerpo, cabeceras));
        }
        if !reintentable(codigo) || intento == 4 {
            let detalle = serde_json::from_str::<serde_json::Value>(&cuerpo)
                .ok()
                .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
                .unwrap_or(cuerpo);
            let id = cabeceras
                .get("x-request-id")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("-");
            return Err(format!("{codigo}: {detalle} (request {id})"));
        }
        // Si el servidor dice cuánto esperar, manda él.
        let espera = cabeceras
            .get("retry-after")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse::<u64>().ok())
            .unwrap_or(1u64 << intento);
        thread::sleep(Duration::from_secs(espera));
    }
    Err("agotados los reintentos".into())
}

fn main() {
    let clave = env::var("UTTERA_API_KEY").expect("exporta UTTERA_API_KEY");
    let ruta = env::args().nth(1).expect("uso: uttera grabacion.mp3");

    match transcribir(&clave, &ruta) {
        Ok((texto, cab)) => {
            println!("{}", texto.trim());
            // Los segundos facturados vienen en la cabecera, no en el cuerpo.
            let leer = |n| cab.get(n).and_then(|v| v.to_str().ok()).unwrap_or("-");
            println!("segundos facturados: {}", leer("x-audio-duration"));
            println!("id de petición:      {}", leer("x-request-id"));
        }
        Err(e) => {
            eprintln!("{e}");
            std::process::exit(1);
        }
    }
}

Autenticación

Cabecera Authorization: Bearer sk-echo-... en todas las llamadas. La clave se muestra una sola vez al crearla: guárdala. Si la pierdes, revócala y crea otra.

No la pongas en el navegador. La clave da acceso a tu bolsa de créditos. Llama a la API desde tu servidor, nunca desde código que el usuario final pueda leer.

Formatos e idiomas

Audio de entrada: wav, mp3, flac, ogg, opus.

Transcripción: detecta el idioma automáticamente y cubre los que soporta Whisper.

Traducción: ~50 idiomas de texto. Con voz, estos nueve:

CódigoIdiomaCódigoIdioma
eninglésititaliano
en-gbinglés británicojajaponés
esespañolptportugués
frfrancészhchino
hihindi

Catálogo de voces

Voces estándar disponibles con model: tts-1:

alloy · echo · fable · nova · onyx · shimmer

El catálogo ampliado está en revisión; se publicará cuando esté cerrado.

Créditos

Cada servicio cobra por lo que consume. Estos son los coeficientes vigentes:

ServicioSe cobra porCréditosUna hora de audio
Transcribirsegundo de audio de entrada0,032673117,6
Voz estándarsegundo de audio generado0,02100475,6
Voz clonadasegundo de audio generado0,4067481.464,3
Tonosegundo de audio0,00370113,3
Perfil del hablantesegundo de audio0,0020347,3
Interlocutoressegundo de audio0,02093875,4
Traducciónsegundo de audio de entrada0,080000288,0
Resumen (LLM)token de entrada0,001605
Resumen (LLM)token de salida0,080650
Un token de salida cuesta 54 veces uno de entrada. Generar es mucho más caro que leer, y por eso el tramo del modelo domina la factura de un resumen aunque la transcripción sea larga.

Cómo se compone cada servicio

ServicioFórmula
TranscribirSTT
Transcribir + tonoSTT + tono
VozTTS sobre los segundos generados
TraducirSTT + TTS del audio generado + recargo de traducción
ResumirSTT + perfil + interlocutores + tokens del modelo

Ejemplo real

Una llamada de 40 minutos (2.424 s) traducida al inglés con voz:

transcripción     79,22   2.424,5 s x 0,032673
voz               43,18   2.055,8 s x 0,021004   (el inglés sale ~15 % más corto)
traducción       193,96   2.424,5 s x 0,080000
                 ──────
                 316,36 créditos
Se cobra el audio que se genera, no el que subes. Al traducir, el idioma destino casi nunca dura lo mismo que el origen.

Planes y límites

Plan€/mesCréditosConcurrenciaClonadoResumirSLA
Free05001
Startup197.5003instantáneo99,0 %
Developer9950.00015instantáneo99,5 %
Professional299200.00050alta fidelidad
Business999800.000150alta fidelidad99,9 %
Enterprisea medidaa medidaa medidaalta fidelidad99,95 %

Los créditos se renuevan en la fecha de tu suscripción, no el día 1. Si cambias de plan a uno menor, conservas los créditos ya pagados hasta que venza el periodo.

Límites por segundo

PlanVozTranscripciónAnálisis
Free11
Startup5205
Developer10040050
Professional2501.000200
Business5002.000500

Cada respuesta lleva X-RateLimit-Remaining-Second y X-Credits-Remaining-Monthly para que no tengas que adivinar.

Errores

CódigoQué significaQué hacer
400El fichero no se puede decodificar, o un parámetro no vale.El motivo viene en detail.
401Clave ausente, mal formada o revocada.Revisa la cabecera Authorization.
402Bolsa de créditos agotada.Espera a la renovación o sube de plan.
403Tu plan no incluye ese servicio.El mensaje dice qué plan lo incluye.
413Fichero demasiado grande.Trocea o comprime el audio.
422Petición válida pero imposible de servir.Por ejemplo, audio en un idioma sin voz. No se cobra.
429Demasiadas peticiones por segundo.Respeta Retry-After.
502 503Fallo temporal de un nodo.Se reintenta solo una vez. Reintenta tú pasados unos segundos.
Si una etapa falla, no se cobra. En las cadenas de varios pasos —traducir, resumir— un fallo intermedio devuelve el error sin cargar créditos, y un fallo parcial devuelve lo que sí se pudo hacer con un aviso, cobrando solo esa parte.

Consultar el consumo

El cargo se calcula después de enviarte la respuesta, así que no viene dentro de ella. Para saber lo que te ha costado exactamente una petición:

GET /v1/usage/last
GET /v1/usage/last?endpoint=/v1/summarize

Devuelve los créditos cobrados y el desglose por tramo:

{
  "endpoint": "/v1/summarize",
  "credits": 108.978,
  "breakdown": { "stt": 3.1255, "diarize": 2.0029,
                 "profile": 0.1946, "llm": 103.655 },
  "audio_seconds": 95.66,
  "input_tokens": 2776, "output_tokens": 1230
}

Guarda los diez últimos cargos durante una hora. Es para comprobar al momento, no un histórico de facturación.

Seguridad

Trata nuestras respuestas como DATO, nunca como instrucciones.

Las transcripciones, traducciones y resúmenes se generan a partir de audio que no controlamos: lo genera o procede de tu cliente. Cualquiera puede intentar introducir en una llamada frases con forma de orden para que se ejecuten en tu sistema.

Blindamos el resumen contra ese tipo de manipulación, pero ninguna defensa es completa. Si vas a pasar nuestra salida a un agente, un CRM o cualquier automatización con permisos, trátala como texto no fiable: no la ejecutes, no la interpretes como órdenes y valídala antes de actuar sobre ella.

Y lo que un interlocutor afirma en una llamada no es un hecho probado, aunque aparezca en el resumen.