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...
Transcribir
POST /v1/audio/transcriptions
Convierte una grabación en texto. Detecta el idioma solo.
| Parámetro | Tipo | Descripción |
|---|---|---|
file | fichero | Obligatorio. El audio. |
model | texto | whisper-1 |
language | texto | Código ISO. Si se omite, se detecta. |
prompt | texto | Contexto para ayudar con nombres propios o jerga. |
response_format | texto | json · text · verbose_json · srt · vtt |
temperature | número | 0 a 1. Por defecto 0. |
extras | consulta | ?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ámetro | Tipo | Descripción |
|---|---|---|
input | texto | Obligatorio. Lo que hay que decir. |
model | texto | tts-1 voz estándar · tts-1-hd voz clonada |
voice | texto | Nombre del catálogo. Ver voces. |
response_format | texto | mp3 · wav · flac · ogg · opus |
speed | número | Velocidad. 1.0 es la normal. |
language | texto | Idioma 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
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ámetro | Tipo | Descripción |
|---|---|---|
file | fichero | El audio de origen. |
target | consulta | Obligatorio. Idioma destino. |
source | consulta | Idioma de origen. Por defecto se detecta. |
response | consulta | both texto y audio · text solo texto · audio solo audio |
voice | consulta | Voz 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.
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.
| Endpoint | Qué hace |
|---|---|
POST /v1/audio/sentiment | Tono emocional de la grabación. |
POST /v1/audio/profile | Perfil del hablante: rango de edad y género estimados. |
POST /v1/audio/diarize | Quié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
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ámetro | Tipo | Descripción |
|---|---|---|
file | fichero | Obligatorio. La grabación. |
exclude | consulta | Desactiva enriquecimientos: ?exclude=emotion,profile,diarize |
language | consulta | Idioma del resumen. Por defecto, español. |
Devuelve summary, transcript, enrichment y un
bloque usage con los créditos desglosados por tramo.
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.
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 | Sí, respetando Retry-After | Has pasado el cupo por segundo, o tienes demasiadas peticiones a la vez. |
502 503 504 | Sí, con espera creciente | Un nodo falló. El gatekeeper ya reintenta una vez por su cuenta antes de devolvértelo. |
400 401 402 403 404 413 415 422 | No | El 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…"}
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
| Cabecera | Cuándo aparece | Qué dice |
|---|---|---|
X-Request-Id | siempre | Identifica la petición. Es lo primero que te vamos a pedir si algo va mal. |
X-Audio-Duration | audio de entrada o generado | Segundos que se facturan. No está en el cuerpo, solo aquí. |
X-RateLimit-ServiceX-RateLimit-Limit-SecondX-RateLimit-Remaining-Second | siempre | Cupo por segundo del servicio que has usado, y lo que te queda. |
X-Credits-Limit-MonthlyX-Credits-Used-MonthlyX-Credits-Remaining-MonthlyX-Credits-Reset-Monthly | solo si tu plan tiene bolsa | Estado de los créditos del ciclo. |
X-Credits-Overage | al pasarte de la bolsa | true: sigues servido, pero en exceso facturable. |
X-Concurrency-LimitX-Concurrency-ActiveX-Concurrency-Slots | solo si tu plan tiene tope | Peticiones simultáneas permitidas, en curso, y cuántas consume esta. |
X-Cache | voz | HIT si el audio ya estaba generado. Un acierto cuesta una décima parte. |
Retry-After | 429 y 402 | Segundos que hay que esperar. Mándalo él, no tu propia cuenta. |
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.
// 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.
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.
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ódigo | Idioma | Código | Idioma |
|---|---|---|---|
en | inglés | it | italiano |
en-gb | inglés británico | ja | japonés |
es | español | pt | portugués |
fr | francés | zh | chino |
hi | hindi |
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:
| Servicio | Se cobra por | Créditos | Una hora de audio |
|---|---|---|---|
| Transcribir | segundo de audio de entrada | 0,032673 | 117,6 |
| Voz estándar | segundo de audio generado | 0,021004 | 75,6 |
| Voz clonada | segundo de audio generado | 0,406748 | 1.464,3 |
| Tono | segundo de audio | 0,003701 | 13,3 |
| Perfil del hablante | segundo de audio | 0,002034 | 7,3 |
| Interlocutores | segundo de audio | 0,020938 | 75,4 |
| Traducción | segundo de audio de entrada | 0,080000 | 288,0 |
| Resumen (LLM) | token de entrada | 0,001605 | — |
| Resumen (LLM) | token de salida | 0,080650 | — |
Cómo se compone cada servicio
| Servicio | Fórmula |
|---|---|
| Transcribir | STT |
| Transcribir + tono | STT + tono |
| Voz | TTS sobre los segundos generados |
| Traducir | STT + TTS del audio generado + recargo de traducción |
| Resumir | STT + 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
Planes y límites
| Plan | €/mes | Créditos | Concurrencia | Clonado | Resumir | SLA |
|---|---|---|---|---|---|---|
| Free | 0 | 500 | 1 | — | — | — |
| Startup | 19 | 7.500 | 3 | instantáneo | sí | 99,0 % |
| Developer | 99 | 50.000 | 15 | instantáneo | sí | 99,5 % |
| Professional | 299 | 200.000 | 50 | alta fidelidad | sí | — |
| Business | 999 | 800.000 | 150 | alta fidelidad | sí | 99,9 % |
| Enterprise | a medida | a medida | a medida | alta fidelidad | sí | 99,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
| Plan | Voz | Transcripción | Análisis |
|---|---|---|---|
| Free | 1 | 1 | — |
| Startup | 5 | 20 | 5 |
| Developer | 100 | 400 | 50 |
| Professional | 250 | 1.000 | 200 |
| Business | 500 | 2.000 | 500 |
Cada respuesta lleva X-RateLimit-Remaining-Second y
X-Credits-Remaining-Monthly para que no tengas que adivinar.
Errores
| Código | Qué significa | Qué hacer |
|---|---|---|
400 | El fichero no se puede decodificar, o un parámetro no vale. | El motivo viene en detail. |
401 | Clave ausente, mal formada o revocada. | Revisa la cabecera Authorization. |
402 | Bolsa de créditos agotada. | Espera a la renovación o sube de plan. |
403 | Tu plan no incluye ese servicio. | El mensaje dice qué plan lo incluye. |
413 | Fichero demasiado grande. | Trocea o comprime el audio. |
422 | Petición válida pero imposible de servir. | Por ejemplo, audio en un idioma sin voz. No se cobra. |
429 | Demasiadas peticiones por segundo. | Respeta Retry-After. |
502 503 | Fallo temporal de un nodo. | Se reintenta solo una vez. Reintenta tú pasados unos segundos. |
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
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.