Callsistdocs
Guías

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
                      └─▶ failed

Mientras 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 hechos

No 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:

AvisoSignificado
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:iabSe 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:

AvisoQué hacer
attribution_heuristic_disagreesLa heurística y el modelo no coinciden en quién es el agente. Los roles pueden estar invertidos
diarization_single_speakerSe detectó una sola voz
diarization_low_minority_shareUn hablante ocupa casi toda la llamada: reparto poco fiable
diarization_unavailableNo hubo separación de hablantes. speaker y speakerRole vienen a null
stereo_source_availableEl audio traía canales separados que no se aprovechan
lid_tie:es~caDos idiomas empatados en la detección
lid_local_unavailable, lid_failed: …Falló la detección de idioma
asr_no_segmentsEl 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.

On this page