Sondear y leer el resultado
El ciclo de vida, la respuesta completa y las dos comprobaciones que hay que hacer antes de usar nada.
GET /v1/transcripts/{id}. Scope: transcripts:read.
Ciclo de vida
queued ──▶ processing ──▶ completed
└─▶ failedMientras no sea completed, la respuesta es corta:
{
"id": "tr_MoKLFufBqnzJ",
"status": "processing",
"created_at": "2026-07-29T08:13:02.114Z",
"completed_at": null,
"metadata": { "ticket": "T-91823" },
"error": null
}Con status: "failed", error es { "message": "…" }. Un fallo no se factura nunca.
El estado cancelled se puede filtrar en el listado y no lo produce nadie: no existe
endpoint de cancelación.
Sondear sin comerse el límite
La regla: sondea solo lo que está en vuelo, nunca el lote entero. No hay cubo aparte
para consultar estado, así que 400 transcripciones cada 10 segundos son 2 400 peticiones
por minuto y un 429 inmediato.
import time, httpx
FINALES = {"completed", "failed", "cancelled"}
def esperar(cliente, ids, intervalo=10.0, limite_s=3600.0):
pendientes, hechos = set(ids), {}
limite = time.monotonic() + limite_s
while pendientes and time.monotonic() < limite:
time.sleep(intervalo)
restantes = None
for tid in list(pendientes):
r = cliente.get(f"{BASE}/transcripts/{tid}")
if r.status_code == 429:
time.sleep(int(r.headers.get("retry-after", 30)))
break
r.raise_for_status()
restantes = int(r.headers.get("x-ratelimit-remaining", 300))
cuerpo = r.json()
if cuerpo["status"] in FINALES:
hechos[tid] = cuerpo
pendientes.discard(tid)
# Frenar antes de chocar. `X-RateLimit-Reset` vale 0 mientras la petición se
# permite, así que usarlo aquí sería dormir cero segundos.
if restantes is not None and restantes < 50:
time.sleep(15)
for tid in pendientes:
hechos[tid] = {"id": tid, "status": "timeout"}
return hechosNo uses estimated_completion como timeout: es siempre «ahora + 6 minutos», una
constante.
La respuesta completa
Ejemplo real, de una llamada de 217 s con los siete módulos activos:
{
"id": "tr_MoKLFufBqnzJ",
"status": "completed",
"metadata": { "ticket": "T-91823" },
"language": "es",
"language_confidence": 0.95,
// Duraciones, en SEGUNDOS
"audio_duration": 216.999, // lo que dura el audio que enviaste
"billed_duration": 240, // minutos empezados × 60 — es lo que se factura
"speech_duration": 96.538, // habla real tras quitar silencios
"text": "Acme Telecom, buenos días, le atiende Marta…",
"words": [{ "text": "Acme", "start": 0, "end": 0.62, "method": "approx" }],
"utterances": [
{
"speaker": "1",
"speakerRole": "agent", // ⚠ camelCase, a diferencia del resto del JSON
"text": "Acme Telecom, buenos días…",
"start": 0,
"end": 5.2
}
],
"speaker_role_confidence": 0.9,
"understanding": { /* … */ },
"pii": null,
"metrics": { /* … */ },
"warnings": [],
"usage": { "total_eur": 0.041 }
}speakerRole está en camelCase dentro de utterances[] y de
pii.utterances_redacted[], en un JSON donde todo lo demás es snake_case. Escribirlo
bien la primera vez sale más barato que descubrirlo.
Leer el resultado
Dos comprobaciones antes de usar nada. Las dos son errores de datos silenciosos: no lanzan excepción, solo dan un resultado equivocado con toda confianza.
const t = respuesta;
const w = t.warnings ?? [];
// 1. ¿Son fiables los roles agente/cliente?
const rolesFiables =
!w.some(x => ['attribution_heuristic_disagrees',
'diarization_single_speaker',
'diarization_unavailable'].includes(x)) &&
(t.speaker_role_confidence ?? 0) >= 0.6;
// 2. Con redacción activa, el texto tapado NO es `text`.
const texto = t.pii ? t.pii.text_redacted : t.text;
const turnos = t.pii ? t.pii.utterances_redacted : t.utterances;
// 3. `understanding` es {} cuando no se pidió nada: comprueba claves, no el objeto.
const resumen = t.understanding?.summarization?.text ?? null;Los detalles de cada una están en Hablantes y roles y en Redacción de PII.
warnings — leerlo siempre
Es la lista de lo que se pidió y no se entregó. Un módulo con warning no se factura.
De comprensión:
| Aviso | Significado |
|---|---|
module_unavailable:<módulo> | Se pidió el módulo y no llegó ningún resultado |
module_failed:<módulo> | El modelo contestó algo que no se pudo validar |
feature_unavailable:<opción> | Una opción que no es un módulo no se pudo cumplir |
taxonomy_unavailable:iab | Se pidió taxonomía IAB, que no existe; se usó libre |
De audio y hablantes — son los que dicen cuánto fiarse de speakerRole y de
language:
| Aviso | Qué hacer |
|---|---|
attribution_heuristic_disagrees | La heurística y el modelo no coinciden en quién es el agente. Los roles pueden estar invertidos |
diarization_single_speaker | Se detectó una sola voz |
diarization_low_minority_share | Un hablante ocupa casi toda la llamada: reparto poco fiable |
diarization_unavailable | No hubo separación de hablantes. speaker y speakerRole vienen a null |
stereo_source_available | El audio traía canales separados que no se aprovechan |
lid_tie:es~ca | Dos idiomas empatados en la detección |
lid_local_unavailable, lid_failed: … | Falló la detección de idioma |
asr_no_segments | El ASR no devolvió nada. Audio en silencio o inservible |
vad_failed: … | Falló la detección de voz; se transcribió el audio entero |
Los que acaban en : <mensaje> no son códigos estables: llevan pegado el texto de la
excepción. Compara con startswith("lid_failed"), no con igualdad.
Marcas de tiempo
start y end van en segundos con decimales, en words y en utterances.
words[].method vale "approx": los tiempos se derivan de los segmentos del ASR y se
refinan con la energía de la señal, con precisión de ±200 ms. Sirven para navegar el
audio; no sirven para cortar o silenciar tramos con precisión.
Listado, subtítulos y borrado
GET /v1/transcripts está paginado por cursor, no por offset: repite pasando
before = next_before mientras has_more sea true.
Pasa next_before tal cual, sin reformatearlo. Lleva microsegundos, y recortarlo a
milisegundos —lo que hace new Date(...).toISOString() en JavaScript— se salta las filas
creadas dentro de ese mismo milisegundo. No se repite ninguna: faltan.
GET /v1/transcripts/{id}.srt, .vtt y .txt dan subtítulos y texto plano, solo con
status: "completed"; en otro caso devuelven 409.
DELETE /v1/transcripts/{id} borra la fila, los fragmentos, los vectores y el audio. No
es reversible, y es la única forma de que algo desaparezca de verdad — ver
Retención y borrado.