# Documentación de Callsist (/) Callsist hace tres cosas: 1. **Transcribe audio** con hablantes separados, rol de agente o cliente, marcas de tiempo y siete módulos de comprensión opcionales — `POST /v1/transcripts`. 2. **Responde como un LLM**, con la forma de la API de OpenAI — `POST /v1/chat/completions`. 3. **Genera embeddings** — `POST /v1/embeddings`. Y hay cuatro cosas que **no** hace, dichas aquí y no en una nota al pie para que nadie las descubra a mitad de una integración: * **No almacena ni busca vectores.** `/v1/embeddings` devuelve los vectores y ahí acaba su trabajo. Quien monte un RAG guarda los vectores en su propio almacén. * **No ejecuta plantillas de análisis** sobre texto propio. Un análisis se monta como un prompt y se manda a `/v1/chat/completions`. * **No hay tool calling.** `tools` y `tool_choice` se descartan en silencio. * **No hay reconocimiento de emociones por señal acústica**, ni lo habrá. El sentimiento se infiere del texto y solo del cliente, por una prohibición legal que se explica en [su sitio](/guias/comprension#sentiment_analysis). ## Lo mínimo para empezar [#lo-mínimo-para-empezar] ```bash curl https://api.callsist.com/v1/me -H "Authorization: Bearer $CALLSIST_API_KEY" ``` Si eso devuelve tu organización y tus scopes, el resto de esta documentación aplica. ## Cómo usar esto con un agente [#cómo-usar-esto-con-un-agente] Esta documentación está escrita para que **un agente de IA pueda leerla entera y hacer la integración sin poder preguntar**, así que dice tanto lo que funciona como lo que no. Hay dos URLs pensadas para eso: | URL | Qué trae | | ---------------------------------- | --------------------------------------------------- | | [`/llms.txt`](/llms.txt) | El índice, con una línea por página | | [`/llms-full.txt`](/llms-full.txt) | La documentación entera en un solo fichero de texto | El aviso que conviene darle: la API **se parece** a la de OpenAI en `/v1/chat/completions` y a la de AssemblyAI en `/v1/transcripts`, y un agente que dé por hecho el resto del comportamiento de esas dos escribirá código que falla en silencio. Pedir `tools`, por ejemplo, no da error: se ignora y el modelo contesta en prosa. # Autenticación y scopes (/empezar/autenticacion) Toda ruta bajo `/v1` va autenticada con una cabecera: ```http Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx ``` Las claves empiezan por `sk_live_`, se crean en [app.callsist.com/keys](https://app.callsist.com/keys) y **se muestran una sola vez**. No hay claves de prueba. Existieron `sk_test_` y se retiraron: no se regalan resultados, así que cada llamada consume saldo real. No es caro —el recorrido completo sobre un audio corto son unos 0,011 €— pero conviene saberlo antes de montar un bucle. ## Scopes [#scopes] Cada clave lleva una lista de permisos. Una petición sin el scope necesario devuelve [`403 missing_scope`](/referencia/errores#missing_scope). | Scope | Da acceso a | | ------------------- | ------------------------------------------------------------- | | `transcripts:write` | `POST`/`DELETE /v1/transcripts`, gestión de webhooks | | `transcripts:read` | `GET /v1/transcripts`, `GET /v1/transcripts/{id}`, subtítulos | | `files:write` | `POST`/`GET`/`DELETE /v1/files` | | `llm:invoke` | `POST /v1/chat/completions` | | `embeddings:invoke` | `POST /v1/embeddings` | | `usage:read` | `GET /v1/balance` | | `understand:write` | Reservado. Hoy no lo exige ningún endpoint | ## `GET /v1/me` [#get-v1me] Lo primero que hay que llamar al integrar. No consume saldo. ```json { "organization": { "id": "aef52135-…", "slug": "acme-bpo" }, "key": { "id": "e03e9a93-…", "environment": "live", "scopes": ["transcripts:write", "transcripts:read", "files:write", "llm:invoke", "embeddings:invoke", "usage:read"] }, "default_retention": "30d" } ``` ## `GET /v1/balance` [#get-v1balance] Requiere `usage:read`. **Conviene consultarlo antes de encolar un lote grande**: sin saldo, cada `POST /v1/transcripts` devuelve [`402`](/referencia/errores#insufficient_credit) sin procesar nada. ```json { "balance_eur": 109.9, "currency": "EUR", "spend_cap_eur": null, "environment": "live" } ``` ## Lista blanca de IPs [#lista-blanca-de-ips] Una clave puede restringirse a un conjunto de IPs. Si la petición llega desde fuera, la respuesta **no** es el código que parecería: es [`403 missing_scope`](/referencia/errores#missing_scope) con `param: "ip_allowlist"`. El código [`ip_not_allowed`](/referencia/errores#ip_not_allowed) está publicado y no se emite nunca. Es el error más desconcertante de la API y por eso está dicho aquí y no solo en la tabla: si una clave que funcionaba deja de funcionar desde una máquina nueva, mira la lista blanca antes que los scopes. ## Un detalle de operación [#un-detalle-de-operación] **Una clave revocada sigue funcionando hasta 30 segundos**, que es lo que dura la caché de autenticación. Si revocas una clave por una filtración, cuenta medio minuto antes de dar por cerrada la puerta. # Primera transcripción (/empezar/primera-transcripcion) Cuatro llamadas: comprobar la clave, subir el audio, encolar la transcripción y recoger el resultado. La transcripción es **asíncrona** —devuelve un id y el resultado se recoge después—, y esa es la única parte que sorprende a quien viene de una API síncrona. ## 1. Comprobar la clave [#1-comprobar-la-clave] ```bash export CALLSIST_API_KEY="sk_live_…" curl https://api.callsist.com/v1/me \ -H "Authorization: Bearer $CALLSIST_API_KEY" ``` Si esto devuelve tu organización y tus scopes, sigue. Si no, ve a [Autenticación](/empezar/autenticacion). ## 2. Subir el audio [#2-subir-el-audio] ```bash curl -X POST https://api.callsist.com/v1/files \ -H "Authorization: Bearer $CALLSIST_API_KEY" \ -F "file=@llamada.mp3" ``` ```json { "id": "file_9xKp2mQvRt4L", "object": "file", "filename": "llamada.mp3", "bytes": 427617, "content_type": "audio/mpeg", "created_at": "2026-07-29T08:12:44.812Z" } ``` También se puede pasar una URL pública en lugar de subir el fichero. Con fichero subido la estimación de coste es exacta porque se conoce el tamaño; con URL se asume una duración conservadora. Los detalles, en [Subir audio](/guias/subir-audio). ## 3. Encolar la transcripción [#3-encolar-la-transcripción] ```bash curl -X POST https://api.callsist.com/v1/transcripts \ -H "Authorization: Bearer $CALLSIST_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: file_9xKp2mQvRt4L" \ -d '{ "file_id": "file_9xKp2mQvRt4L", "language_hints": ["es", "ca"], "understanding": { "topic_detection": { "taxonomy": "contact_center" }, "content_moderation": {}, "pii_redaction": { "policy": "entity_name" }, "profanity_filter": { "policy": "mask" }, "sentiment_analysis": { "scope": "customer" }, "summarization": { "format": "bullets" }, "action_items": true }, "retention": "30d", "metadata": { "ticket": "T-91823" } }' ``` Responde `202` con el id y una estimación de coste: ```json { "id": "tr_MoKLFufBqnzJ", "object": "transcript", "status": "queued", "estimated_cost": { "eur": 0.0453, "assumed_seconds": 240 } } ``` Cuatro decisiones metidas en ese cuerpo, y todas importan: * **`Idempotency-Key` es el `file_id`.** Un reintento de red sin clave de idempotencia crea una segunda transcripción **y la cobra**. Usando el `file_id`, reintentar devuelve la original con `Idempotency-Replayed: true` y no cuesta nada. * **`language_hints` es lo que más mejora la detección de idioma.** Pon solo los que de verdad aparecen en tus llamadas: quitar al italiano de la competición mejora notablemente el castellano contra el catalán. * **Los siete módulos o ninguno.** El pack cuesta 0,29 €/h y solo se aplica con los siete; seis sueltos cuestan 0,35 €/h. **Pedir los siete sale más barato que pedir seis.** * **`metadata` es lo que hace útil la integración.** Ahí va el id del ticket, del agente y del cliente; vuelve tal cual al leer la transcripción y te ahorra mantener una tabla de correspondencias aparte. ## 4. Recoger el resultado [#4-recoger-el-resultado] ```bash curl https://api.callsist.com/v1/transcripts/tr_MoKLFufBqnzJ \ -H "Authorization: Bearer $CALLSIST_API_KEY" ``` Mientras no esté lista, la respuesta es corta: `status` vale `queued` o `processing`. Sondea cada 10 segundos. Cuando pase a `completed`, llega todo: ```json { "id": "tr_MoKLFufBqnzJ", "status": "completed", "language": "es", "audio_duration": 216.999, "billed_duration": 240, "text": "Acme Telecom, buenos días, le atiende Marta. ¿En qué puedo ayudarle?…", "utterances": [ { "speaker": "1", "speakerRole": "agent", "text": "…", "start": 0, "end": 5.2 }, { "speaker": "0", "speakerRole": "customer", "text": "…", "start": 5.66, "end": 15.82 } ], "speaker_role_confidence": 0.9, "understanding": { "…": "…" }, "pii": { "…": "…" }, "warnings": [], "usage": { "total_eur": 0.041 } } ``` **Antes de usar nada de ahí, lee `warnings`.** Es la lista de lo que pediste y no llegó. Un módulo que aparece en `warnings` no está en `understanding`, y tratarlo como vacío es un error de datos disfrazado de resultado. Y si activaste la redacción de PII, el texto tapado **no** es `text`: está en `pii.text_redacted`. Las dos cosas, en [Leer el resultado](/guias/sondear#leer-el-resultado). ## El flujo completo, resumido [#el-flujo-completo-resumido] ``` 1. GET /v1/me ← comprobar clave y scopes 2. GET /v1/balance ← ¿hay saldo para el lote? 3. POST /v1/files ← subir el mp3 → file_id 4. POST /v1/transcripts ← con Idempotency-Key → tr_… (202) 5. GET /v1/transcripts/{id} ← sondear cada 10 s hasta completed | failed 6. leer warnings ← ¿llegó lo que pediste? 7. POST /v1/chat/completions ← análisis con plantilla, si hace falta ``` # Los siete módulos de comprensión (/guias/comprension) Se piden dentro de `understanding` en el `POST`. Se ejecutan en **tres llamadas al LLM**, no siete, y **un grupo que falla solo se lleva a sus módulos**: | Grupo | Módulos | | --------- | ------------------------------------------------------------------------ | | `insight` | `topic_detection`, `summarization`, `action_items`, `sentiment_analysis` | | `safety` | `content_moderation`, `profanity_filter` | | `pii` | `pii_redaction` | **Pedir los siete es más barato que pedir seis.** El pack cuesta 0,29 €/h y solo se aplica si están los siete; seis sueltos salen a 0,35 €/h. Si no mandas `understanding`, la respuesta trae `"understanding": {}` — un objeto vacío, no `null`. Comprueba las claves que esperas, no si el objeto existe. ## `topic_detection` [#topic_detection] ```json { "topics": [ { "label": "tech_configuration", "relevance": 0.9 }, { "label": "survey", "relevance": 0.5 } ], "taxonomy": "contact_center", "taxonomy_version": 1 } ``` Con `taxonomy: "contact_center"` (el defecto) los `label` salen **siempre** de una lista cerrada de 40 identificadores estables. Lo que el modelo invente fuera de la lista se descarta. Es lo que permite agregar por tema entre llamadas e idiomas — con etiquetas libres, «problema de factura» y «incidencia de facturación» son dos temas distintos. **Facturación** — `billing_invoice_query`, `billing_dispute`, `billing_error`, `billing_payment_method`, `billing_debt`, `billing_refund`, `billing_tariff_change` **Contratación** — `contract_new`, `contract_cancel`, `contract_port`, `contract_renewal`, `contract_holder_change`, `contract_modify`, `contract_promotion` **Técnico** — `tech_outage`, `tech_degraded`, `tech_configuration`, `tech_installation`, `tech_equipment_fault`, `tech_equipment_replace` **Información y cuenta** — `info_product`, `account_data_change`, `account_access` **Logística** — `logistics_appointment`, `logistics_delivery`, `logistics_pickup` **Asistencia y seguros** — `assistance_roadside`, `assistance_towing`, `insurance_claim`, `insurance_coverage` **Relación** — `complaint_formal`, `complaint_service`, `escalation_supervisor`, `compliment`, `retention` **Comercial y otros** — `sales_upsell`, `survey`, `call_misdirected`, `call_transfer`, `call_no_content` Con `taxonomy: "open"` las etiquetas son libres. **`taxonomy: "iab"` no está implementada**: se acepta, se cae a etiquetas libres y se avisa con `taxonomy_unavailable:iab` en `warnings`. ## `summarization` [#summarization] ```json { "text": "- El cliente llama porque ha introducido mal el PIN…\n- El agente verifica…" } ``` `format` puede ser `bullets` (por defecto, viñetas separadas por `\n`), `paragraph` o `headline`. El resumen sale **en el idioma de la conversación**. `context` —hasta 1 000 caracteres— se inyecta en el prompt: úsalo para el sector o el nombre del cliente. ## `action_items` [#action_items] ```json { "items": [ { "text": "Enviar el duplicado de la factura de junio", "owner": "agente", "due": null } ] } ``` `owner` y `due` valen `null` cuando no se dicen en la llamada. **Una lista vacía es un resultado válido y frecuente**: no la trates como un fallo. ## `content_moderation` [#content_moderation] ```json { "findings": [ { "category": "hate", "severity": 0.7, "excerpt": "…" } ] } ``` `severity` va de 0 a 1. Categorías: `violence`, `hate`, `self_harm`, `sexual`, `drugs`, `weapons`. ## `profanity_filter` [#profanity_filter] ```json { "terms": [ { "text": "…" } ] } ``` Las palabras se **contrastan contra el texto real** antes de devolverse: si el modelo se inventa un insulto que nadie dijo, se descarta. ## `sentiment_analysis` [#sentiment_analysis] ```json { "overall": "positive", "by_utterance": [ { "index": 1, "sentiment": "neutral" }, { "index": 24, "sentiment": "positive" } ] } ``` `overall` y `sentiment` valen `positive`, `neutral` o `negative`. `index` apunta a la posición dentro de `utterances`. **`scope` solo admite `"customer"`.** Mandar `"agent"` devuelve [`400 sentiment_scope_not_permitted`](/referencia/errores#sentiment_scope_not_permitted), y no es un capricho: el Reglamento de IA (art. 5.1.f) prohíbe inferir emociones de trabajadores en su entorno laboral, y vincula también al proveedor del sistema. Las intervenciones del agente **no aparecen** en `by_utterance`. Esto no impide evaluar la calidad del trabajo del agente contra una plantilla — eso es evaluación de desempeño sobre hechos observables, no inferencia de emociones. Lo que no se puede es preguntar «¿cómo se sentía el agente?». Tampoco hay reconocimiento de emociones por señal acústica, ni lo habrá. El sentimiento se infiere del texto. ## `pii_redaction` [#pii_redaction] Tiene página propia, porque la forma de leerlo mal es silenciosa y cara: [Redacción de PII](/guias/pii). # Embeddings y RAG (/guias/embeddings) `POST /v1/embeddings`. Scope: `embeddings:invoke`. ```json { "model": "callsist-embed-1", "input": ["texto 1", "texto 2"], "dimensions": 768 } ``` * `input`: cadena o array. **Máximo 256 elementos por petición.** * `dimensions`: opcional. **Por defecto 768.** Si lo pides distinto y el proveedor devuelve otra cosa, la petición **falla** en vez de guardar vectores de dimensión equivocada. * Ventana: 32 000 tokens. ```json { "object": "list", "model": "callsist-embed-1", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0123, -0.0456] } ], "usage": { "prompt_tokens": 412, "total_tokens": 412 } } ``` El orden de `data` corresponde al de `input` por el campo `index`, no por la posición del array. Precio: **0,28 €/M tokens**. Reembeber una base de conocimiento de un millón de tokens cuesta 0,28 €; no es ahí donde está el gasto. ## Callsist no almacena ni busca vectores [#callsist-no-almacena-ni-busca-vectores] `/v1/embeddings` devuelve los vectores y ahí acaba su trabajo. **No existen colecciones, ni upsert, ni búsqueda semántica.** Quien monte un RAG guarda los vectores en su propio pgvector, Qdrant o lo que use. No es una carencia pendiente de tapar: un almacén de vectores es un producto distinto, con su propio ciclo de vida, sus copias de seguridad y su modelo de permisos. Preferimos no fingir que lo tenemos. ## El patrón completo [#el-patrón-completo] ``` 1. POST /v1/embeddings ← embeber los documentos, una vez 2. (tu almacén) ← guardar los vectores, dimensión 768 3. POST /v1/embeddings ← embeber la consulta 4. (tu almacén) ← recuperar los k fragmentos más cercanos 5. POST /v1/chat/completions ← prompt + fragmentos, con response_format json_object ``` El paso que la gente se salta es el 2 con la dimensión correcta: si tu tabla de pgvector está declarada como `vector(1536)` porque venías de otro proveedor, la inserción falla o —peor— truncas. **768.** ## Reembeber cuando cambie el modelo [#reembeber-cuando-cambie-el-modelo] Los vectores de dos modelos distintos no son comparables. Si algún día `callsist-embed-1` se sustituye por otro identificador, hay que reembeber la colección entera: mezclar vectores de dos modelos en el mismo índice da resultados que parecen funcionar y no lo hacen. A 0,28 €/M tokens, reembeber es la parte barata del problema; lo caro es no darse cuenta. # Hablantes y roles (/guias/hablantes) Es lo que hace útil esto para un BPO, y también donde es más fácil construir una métrica falsa con toda confianza. * **`speaker`** es un identificador acústico (`"0"`, `"1"`, …), estable **dentro de una misma llamada** y sin significado entre llamadas. El hablante `"0"` de dos llamadas distintas no es la misma persona. * **`speakerRole`** es `"agent"`, `"customer"` o `null`. **Se atribuye o no se atribuye: no se adivina.** * **`speaker_role_confidence`** dice cuánto fiarse. Medido en llamadas reales de BPO: **0,9** en castellano. Un `0` significa que no se etiquetó ningún rol, y `speakerRole` será `null` en todas las intervenciones. ## El número solo no basta [#el-número-solo-no-basta] En una llamada real **en catalán** salió `speaker_role_confidence: 0.7` —por encima de cualquier umbral razonable— **y los roles estaban mal**: el saludo del agente venía marcado como `customer`. Lo que sí lo dijo fue el aviso `attribution_heuristic_disagrees`, que viajaba en `warnings`. Está exactamente para eso. Regla práctica, en este orden: 1. Si `warnings` contiene `attribution_heuristic_disagrees`, `diarization_single_speaker` o `diarization_unavailable` → **trata la llamada como sin roles.** 2. Si no, y `speaker_role_confidence >= 0.6` → los roles son utilizables. 3. En cualquier otro caso, **no construyas encima métricas por agente.** ```js const w = t.warnings ?? []; const rolesFiables = !w.some(x => ['attribution_heuristic_disagrees', 'diarization_single_speaker', 'diarization_unavailable'].includes(x)) && (t.speaker_role_confidence ?? 0) >= 0.6; ``` ## Qué hacer con las llamadas que no pasan el filtro [#qué-hacer-con-las-llamadas-que-no-pasan-el-filtro] **Apartarlas para revisión manual, no puntuarlas.** Una plantilla de calidad aplicada sobre roles invertidos produce una nota confiada y falsa, que es peor que no tener nota: nadie la va a cuestionar porque viene con un número al lado. En un panel de calidad eso se traduce en tres cubos —puntuadas, apartadas y fallidas— y no en dos. ## Estéreo [#estéreo] Si tu grabación trae agente y cliente en canales separados, hoy **no se aprovecha**: el audio se mezcla a mono y la separación se hace por diarización acústica. Se avisa con `stereo_source_available` en `warnings`. Para grabaciones de contact center esto no suele importar —son mono y la diarización funciona—, pero si tienes estéreo de verdad estás perdiendo una señal que sería perfecta. ## `speakerRole` está en camelCase [#speakerrole-está-en-camelcase] En un JSON donde todo lo demás es `snake_case`, dentro de `utterances[]` y de `pii.utterances_redacted[]` el campo se llama **`speakerRole`**, no `speaker_role`. # Análisis con LLM (/guias/llm) `POST /v1/chat/completions`. Scope: `llm:invoke`. La forma de petición y respuesta es la de OpenAI, así que el SDK oficial funciona apuntando `base_url` a `https://api.callsist.com/v1`. **Lo que cambia es qué campos se atienden.** ## Petición [#petición] ```jsonc { "model": "callsist-llm-1-small", // o "callsist-llm-1-large" "messages": [ { "role": "system", "content": "Eres un analista de calidad de un BPO." }, { "role": "user", "content": "…" } ], "temperature": 0, // 0 a 2 "max_tokens": 4000, // entero positivo, máximo 32000 "stream": false, "response_format": { "type": "json_object" }, "reasoning_effort": "none" // "none" | "low" | "medium" | "high" } ``` **`role` solo admite `system`, `user` y `assistant`.** `content` solo admite **cadena**: el formato multimodal de OpenAI —array de partes— se rechaza con `400`. **Todo campo que no esté en esa lista se descarta en silencio.** No da error: se ignora. Eso incluye `tools`, `tool_choice`, `top_p`, `seed`, `stop`, `n`, `presence_penalty`, `frequency_penalty` y `logprobs`. ## No hay tool calling [#no-hay-tool-calling] `tools` y `tool_choice` **no existen** en el esquema y se descartan sin avisar. Un mensaje con `role: "tool"` se rechaza con `400`. Esto rompe los frameworks de agentes que dan el tool calling por hecho: el modelo contesta en prosa, el framework no encuentra la llamada a herramienta que espera y falla con un mensaje que no apunta a la causa. Para un RAG, el patrón correcto es **recuperar antes y meter el contexto en el prompt**: 1. embebe la consulta con [`POST /v1/embeddings`](/guias/embeddings); 2. busca en tu propio almacén de vectores; 3. mete los fragmentos recuperados en el mensaje `user` o `system`; 4. llama a `/v1/chat/completions` con `response_format: { "type": "json_object" }`. Es lo mismo que haría el tool calling con una vuelta menos. ## `reasoning_effort` — el parámetro que hay que entender [#reasoning_effort--el-parámetro-que-hay-que-entender] Los modelos de chat razonan por defecto y emiten unos 420 tokens de razonamiento oculto por llamada, aunque devuelvan JSON limpio. Callsist manda `reasoning_effort: "none"` automáticamente salvo que pidas otra cosa, y eso es lo correcto para clasificación y extracción: dejarlo activo **duplica el coste del módulo y multiplica por 35 los tokens de salida**. `"low"` **no** ahorra: gasta lo mismo que el defecto. Si quieres razonamiento, pide `"medium"` o `"high"` a sabiendas; si no, no mandes el campo. ## Respuesta [#respuesta] ```json { "id": "chatcmpl_9xKp2mQvRt4L", "object": "chat.completion", "model": "callsist-llm-1-small", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "{\"resultado\":…}" }, "finish_reason": "stop" } ], "usage": { "prompt_tokens": 24318, "completion_tokens": 892, "total_tokens": 25210 } } ``` `finish_reason` es `"stop"` o `"length"`. **Con `"length"` el JSON viene truncado**: compruébalo antes de parsear, o tendrás un error de sintaxis que parece otra cosa. ## JSON estructurado [#json-estructurado] `response_format: { "type": "json_object" }` empuja al modelo a devolver JSON, pero **no lo garantiza a nivel de gramática**. En producción: * pide el esquema exacto en el prompt, campo por campo; * `temperature: 0`; * valida el resultado contra tu esquema y **reintenta una vez** si no valida; * acepta que un campo opcional venga con otra forma sin tirar el resto de la respuesta. ## Streaming [#streaming] Con `"stream": true` la respuesta es SSE con trozos `chat.completion.chunk` y termina con `data: [DONE]`. Tres cosas que hay que saber: * **El stream nunca trae `usage`.** No hace falta mandar `stream_options` (se descarta): Callsist ya lo pide por dentro y la llamada **sí se factura**. Pero el bloque de uso no llega al cliente, así que **si necesitas contar tokens, no uses streaming**. * **Un fallo a mitad de stream se cierra con `[DONE]`, no con un error HTTP**, porque la cabecera ya salió. Un cliente que solo mire `[DONE]` no distingue una respuesta completa de una cortada: comprueba que llegó un trozo con `finish_reason`. * **Nunca mandes `Idempotency-Key` con `stream: true`.** El middleware de idempotencia consume la respuesta entera para memorizarla: el streaming deja de serlo. Para análisis por lotes, no uses streaming. No aporta nada, no da tokens y complica el manejo de errores. ## Timeout [#timeout] El cliente HTTP interno tiene 120 s y hasta 3 reintentos con espera creciente. En el peor caso una petición tarda **varios minutos** en devolver un error, y no hay forma de acotarlo desde la petición. Pon tu propio timeout —60 a 120 s para lotes— y trátalo como reintentable. # Lotes y límites (/guias/lotes) Un lote de cientos de llamadas no es «lo mismo pero más veces»: hay tres límites que gobiernan el diseño y uno que arruina la factura si se ignora. ## Comprobar antes de empezar [#comprobar-antes-de-empezar] Un lote que se queda sin saldo a mitad deja la mitad de las llamadas sin procesar y llena los logs de [`402`](/referencia/errores#insufficient_credit). ```python def comprobar(cliente, llamadas_previstas, minutos_medios): yo = cliente.get(f"{BASE}/me").raise_for_status().json() print(f"Organización {yo['organization']['slug']} · scopes {yo['key']['scopes']}") saldo = cliente.get(f"{BASE}/balance").raise_for_status().json()["balance_eur"] # 0,39 €/h de ASR + 0,29 €/h del pack, sobre minutos empezados. horas = llamadas_previstas * max(1, round(minutos_medios + 0.5)) / 60 estimado = horas * (0.39 + 0.29) if saldo < estimado: raise SystemExit(f"Saldo {saldo:.2f} € insuficiente para ~{estimado:.2f} €.") ``` ## Los tres límites [#los-tres-límites] | Límite | Valor | Consecuencia de diseño | | ------------------------ | ----------------------- | ---------------------------------------- | | Transcripciones en vuelo | **32** por organización | Cola propia de 8 simultáneas | | Peticiones por minuto | **300 por clave** | Dos claves: una transcribe, otra analiza | | Sondeo | Sin cubo aparte | Sondea **solo lo que está en vuelo** | **El techo de 32 es el que gobierna el lote.** Cuenta como «en vuelo» todo lo que esté en `queued` o `processing`. Con 8 simultáneas nunca se toca; con 40 sí. **El cubo del rate limit es por clave, no por organización.** Es una ventana deslizante de 60 s sobre el identificador de la clave: dos claves distintas son dos cubos. Si el mismo proceso transcribe y analiza con la misma clave, el sondeo de estado le quita caudal a las llamadas de negocio. Separarlas cuesta un minuto y evita el `429` que aparece solo cuando el lote es grande — o sea, en producción y no en las pruebas. ## Qué reintentar [#qué-reintentar] * **Reintentables**: `429`, `500`, `503`. Espera exponencial con jitter, respetando `Retry-After` si viene. * **No reintentables**: `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422`. Reintentar gasta cuota y no cambia el resultado. * **`402` es un caso aparte**: para el lote entero, avisa y recarga. ## La idempotencia es lo que evita cobrar dos veces [#la-idempotencia-es-lo-que-evita-cobrar-dos-veces] Un reintento de red sin `Idempotency-Key` crea una segunda transcripción **y la cobra**. Con ella, durante 24 h la misma clave con el mismo cuerpo devuelve la respuesta original y `Idempotency-Replayed: true`, gratis. Usa un valor derivado del trabajo, no un aleatorio: el `file_id`, o el id de la llamada en tu sistema con un sufijo de versión (`llamada-91823-v1`). Un uuid nuevo en cada ejecución no protege de nada, porque un reintento genera otro uuid. Y no la mandes en `POST /v1/files`: ahí el middleware lee el fichero entero en memoria para hashearlo. ## Dónde se va el dinero de verdad [#dónde-se-va-el-dinero-de-verdad] No en la transcripción: en el análisis. Una llamada de 3,5 minutos con los siete módulos son 0,045 €; el mismo análisis con `callsist-llm-1-large` en vez de `small` sube el total por llamada de 0,076 € a 0,115 €. Sobre 400 llamadas son 30,50 € contra 46,00 €. Para extracción y clasificación sobre texto dado —que es lo que es aplicar una plantilla a una transcripción— `small` es el correcto. Y no mandes `reasoning_effort`: dejarlo activo multiplica por 35 los tokens de salida. # n8n (/guias/n8n) Cómo montar el recorrido completo —subir audio, transcribir, analizar— con nodos de n8n. **Procedencia.** Lo que dice esta guía **sobre la API** está verificado contra el código y contra producción, como el resto de esta documentación. Lo que dice **sobre n8n** está escrito contra n8n 1.x a partir de la documentación de sus nodos y **no se ha ejecutado**: los rótulos de la interfaz cambian entre versiones. Si uno no coincide, el que manda es el comportamiento de la API, que sí está comprobado. *** ## 1. Tres cosas que condicionan el diseño del workflow [#1-tres-cosas-que-condicionan-el-diseño-del-workflow] **No hay nodo de Callsist en n8n.** Todo va con **HTTP Request** genérico. Para chat y embeddings se pueden reutilizar los nodos de OpenAI cambiándoles la Base URL, con una excepción grande que está en §7. **No hay modo de pruebas.** Las claves `sk_test_` se retiraron: cada ejecución consume saldo real. No es caro —el recorrido entero sobre un audio corto son \~0,011 €, §10— pero sí es real, y un bucle mal cerrado gasta de verdad. **La transcripción es asíncrona.** `POST /v1/transcripts` devuelve `202` y un id; el resultado se recoge sondeando o por webhook. En n8n eso es un ciclo `Wait → HTTP Request → IF` con el cable de vuelta, y es la parte donde se concentran los errores. *** ## 2. Levantar n8n [#2-levantar-n8n] ```bash docker run -d --name n8n \ -p 127.0.0.1:5678:5678 \ -v n8n_data:/home/node/.n8n \ -e GENERIC_TIMEZONE=Europe/Madrid \ docker.n8n.io/n8nio/n8n ``` El `127.0.0.1:` delante del puerto no es decoración: sin él queda expuesto un panel que va a tener dentro una `sk_live_`. Para llegar desde fuera, túnel SSH: ```bash ssh -L 5678:127.0.0.1:5678 usuario@servidor ``` y abrir `http://localhost:5678`. Si prefieres n8n en tu máquina, `npx n8n` hace lo mismo. Da igual dónde corra mientras tenga salida a internet — salvo que quieras webhooks (§8), que sí exigen que **Callsist** llegue a **n8n** por HTTPS público. *** ## 3. La clave y sus scopes [#3-la-clave-y-sus-scopes] Se crea en `https://app.callsist.com/keys` y se muestra **una sola vez**. Para el recorrido completo hacen falta seis scopes: | Scope | Lo usa | | ------------------- | --------------------------------- | | `files:write` | Subir el audio | | `transcripts:write` | Encolar la transcripción | | `transcripts:read` | Sondear y leer el resultado | | `llm:invoke` | El análisis con plantilla | | `embeddings:invoke` | Embeddings, si montas RAG | | `usage:read` | Consultar el saldo antes del lote | Si la clave lleva lista blanca de IPs, la que tiene que estar es la **IP de salida del servidor donde corre n8n**, no la del navegador con el que editas el workflow. Y el diagnóstico despista: una IP fuera de la lista no devuelve `ip_not_allowed` —ese código está publicado y no se emite nunca— sino `403 missing_scope` con `param: "ip_allowlist"`. *** ## 4. La credencial [#4-la-credencial] **Credentials → New → Header Auth** (la genérica): | Campo | Valor | | ----- | ------------------ | | Name | `Authorization` | | Value | `Bearer sk_live_…` | En cada nodo HTTP Request: *Authentication → Generic Credential Type → Header Auth →* la credencial. *** ## 5. Comprobación de humo [#5-comprobación-de-humo] **Manual Trigger** → **HTTP Request** a `https://api.callsist.com/v1/me`. Si devuelve la organización y la lista de scopes, la autenticación está resuelta y el resto de la guía aplica. Un segundo nodo a `/v1/balance` te dice si hay saldo. Ninguno de los dos cobra. *** ## 6. El flujo de transcripción, nodo a nodo [#6-el-flujo-de-transcripción-nodo-a-nodo] ### 6.1 · Read/Write Files from Disk [#61--readwrite-files-from-disk] Operación *Read File(s)*, apuntando a un `.mp3` o `.wav`. El binario sale en el campo `data`. Si prefieres no meter ficheros en el contenedor, salta este nodo y usa `audio_url` con una URL pública en §6.3. Con fichero subido la estimación de coste es exacta porque se conoce el tamaño; con URL se asume una duración conservadora. ### 6.2 · HTTP Request — `POST /v1/files` [#62--http-request--post-v1files] * URL `https://api.callsist.com/v1/files` * *Send Body* → **Body Content Type: Form-Data** * Body Parameters, un parámetro: * Parameter Type: **n8n Binary File** * Name: `file` ← el nombre del campo no es negociable; otro da `400 missing_parameter` * Input Data Field Name: `data` **Sin cabecera `Idempotency-Key` en este nodo.** El middleware hashea el cuerpo entero para detectar reintentos, y con un multipart eso es cargar hasta 2 GB en memoria (ver [Subir audio](/guias/subir-audio)). Una subida duplicada solo deja un fichero huérfano: no cobra. Devuelve `{ "id": "file_…" }`. ### 6.3 · HTTP Request — `POST /v1/transcripts` [#63--http-request--post-v1transcripts] * *Send Headers* → `Idempotency-Key` = `={{ $json.id }}` * *Send Body* → **JSON**: ```json { "file_id": "={{ $json.id }}", "language": "auto", "language_hints": ["es", "ca"], "understanding": { "topic_detection": { "taxonomy": "contact_center" }, "content_moderation": {}, "pii_redaction": { "policy": "entity_name" }, "profanity_filter": { "policy": "mask" }, "sentiment_analysis": { "scope": "customer" }, "summarization": { "format": "bullets" }, "action_items": true }, "retention": "30d", "metadata": { "origen": "n8n", "ticket": "T-91823" } } ``` Cinco decisiones metidas ahí: * **La `Idempotency-Key` es el `file_id`**, no un aleatorio. Así, re-ejecutar desde este nodo con la misma subida devuelve la transcripción original con `Idempotency-Replayed: true` y **no la vuelve a cobrar**. Con un uuid nuevo por ejecución, cada clic en «Test workflow» es un cargo. * **`language_hints` es lo único del bloque de idioma que se usa.** `language_confidence_threshold`, `speaker_labels`, `speakers_expected`, `channels` y `word_timestamps` se aceptan, no dan error y no cambian nada ([los cinco parámetros que se ignoran](/guias/subir-audio#cinco-parámetros-que-se-aceptan-y-no-hacen-nada)). No construyas ramas del workflow encima de ellos. * **Los siete módulos o ninguno.** El pack (0,29 €/h) solo se aplica con los siete; seis sueltos son 0,35 €/h. Pedir seis sale más caro que pedir siete. * **`sentiment_analysis.scope` solo admite `"customer"`.** `"agent"` es `400 sentiment_scope_not_permitted`: lo prohíbe el Reglamento de IA (art. 5.1.f), no es un bug pendiente. * **`retention` explícito** aunque coincida con el defecto de la organización: deja la intención escrita en la petición y no en una configuración que alguien puede cambiar. Nada de `webhook_url` en el cuerpo: se rechaza con `400`. Los webhooks se registran aparte (§8). Y `metadata` es lo que hace útil la integración desde n8n en particular: ahí va el id del ticket que traiga el trigger, y vuelve tal cual al leer la transcripción. Sin eso hay que mantener una tabla de correspondencias fuera del workflow. ### 6.4 · Wait [#64--wait] *Resume: After Time Interval*, **15 segundos**. ### 6.5 · HTTP Request — sondear [#65--http-request--sondear] ``` =https://api.callsist.com/v1/transcripts/{{ $('POST transcripts').item.json.id }} ``` Referencia el nodo del `202` por su nombre, no `$json`: en el ciclo, `$json` viene del Wait o de la vuelta anterior. ### 6.6 · Switch — la salida del ciclo [#66--switch--la-salida-del-ciclo] Tres ramas sobre `{{ $json.status }}`: | Rama | Condición | A dónde va | | ----- | ----------- | -------------------------------------------------------- | | Lista | `completed` | Al análisis (§7) | | Fallo | `failed` | A tu manejo de errores. **Un fallo no se factura nunca** | | Sigue | resto | **De vuelta al nodo Wait** | Ese cable hacia atrás es el bucle. Dos guardas que no son opcionales: * **Un tope de vueltas.** n8n no limita las iteraciones de un ciclo. Añade una condición con `{{ $runIndex }}` —cuántas veces ha corrido el nodo en esta ejecución— y corta a las \~40, que con 15 s son diez minutos. * **No uses `estimated_completion` como timeout.** Es siempre «ahora + 6 minutos», una constante que no mira ni la cola ni la duración del audio (ver [Límites](/referencia/limites#timeouts)). El estado `cancelled` se puede filtrar en el listado pero **no lo produce nadie**: no hay endpoint de cancelación. No hace falta rama para él. *** ## 7. Leer el resultado sin creerse lo que no llegó [#7-leer-el-resultado-sin-creerse-lo-que-no-llegó] Un nodo **Code** entre el `completed` y lo que venga después: ```js const t = $input.first().json; 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; return [{ json: { rolesFiables, texto, turnos, resumen, warnings: w } }]; ``` Las tres comprobaciones, por orden de lo que cuesta descubrirlas tarde: **`text` y `utterances` siguen en claro aunque actives la redacción.** Es deliberado —quien no la pide quiere su transcripción entera— pero significa que un workflow que active `pii_redaction` y luego lea `text` está procesando datos personales en claro creyendo que no. En n8n se pisa con especial facilidad, porque el panel de salida del nodo enseña `text` arriba del todo y `pii` hay que ir a buscarlo. **`warnings` es la lista de lo que pediste y no llegó.** Un módulo que aparece ahí no está en `understanding`, y tratarlo como «vacío» es un error de datos disfrazado de resultado. Un módulo con warning tampoco se factura. **Los roles necesitan las dos señales, no solo el número.** La confianza medida en llamadas reales es 0,9 en castellano. Pero en una llamada en catalán salió 0,7 —por encima de cualquier umbral razonable— y los roles estaban invertidos: el saludo del agente venía como `customer`. Lo dijo `attribution_heuristic_disagrees`, en `warnings`. Si vas a puntuar agentes desde n8n, las llamadas que no pasen esta comprobación hay que **apartarlas para revisión**, no puntuarlas: una plantilla sobre roles invertidos produce una nota confiada y falsa, que es peor que no tener nota. *** ## 8. Los nodos de OpenAI: dónde sirven y dónde no [#8-los-nodos-de-openai-dónde-sirven-y-dónde-no] `/v1/chat/completions` y `/v1/embeddings` tienen la forma de la API de OpenAI, así que los nodos de LangChain de n8n funcionan. Crea una credencial **OpenAI** con `API Key` = tu `sk_live_…` y **Base URL** = `https://api.callsist.com/v1`, y escribe el ID del modelo a mano (`callsist-llm-1-small`, `callsist-llm-1-large`, `callsist-embed-1`). ### ⛔ No lo enchufes a un nodo AI Agent [#-no-lo-enchufes-a-un-nodo-ai-agent] Los agentes de n8n funcionan a base de tool calling y **Callsist no tiene**: `tools` y `tool_choice` se descartan **en silencio**, sin error ni warning. El resultado no es un fallo limpio — el modelo contesta en prosa, n8n no encuentra la llamada a herramienta que espera y el nodo revienta con un mensaje que no apunta a la causa. | Nodo | ¿Sirve? | | ------------------------------------------------------------- | -------------- | | Basic LLM Chain | Sí | | Information Extractor / Text Classifier / Summarization Chain | Sí | | Embeddings OpenAI | Sí (ver abajo) | | **AI Agent**, Tools Agent, cualquier cosa con herramientas | **No** | Dos límites más del mismo tipo: `content` solo admite cadena —el formato multimodal de OpenAI se rechaza con `400`— y `top_p`, `seed`, `stop`, `n` y las penalizaciones se descartan sin avisar. n8n manda algunos por defecto: no rompen nada y tampoco hacen nada. ### Para extracción, HTTP Request directo [#para-extracción-http-request-directo] Más control que el nodo: `temperature: 0`, `response_format: { "type": "json_object" }`, el esquema pedido campo por campo en el prompt. El JSON no está garantizado a nivel de gramática, así que valida la salida y reintenta una vez. Y mira `finish_reason`: con `"length"` el JSON viene **truncado** y parsearlo falla de una forma que parece otra cosa. `reasoning_effort` no hace falta mandarlo: Callsist pone `"none"`, que es lo correcto para clasificar y extraer. Si lo pones a `"low"` no ahorras nada —gasta lo mismo que el defecto—; `"medium"` y `"high"` multiplican los tokens de salida. **Pon timeout en el nodo** (*Settings → Timeout*, 120 s). El cliente HTTP interno de Callsist tiene 120 s y hasta 3 reintentos, así que en el peor caso una petición de chat tarda varios minutos en devolver un error, y no hay forma de acotarlo desde la petición (ver [Límites](/referencia/limites#timeouts)). Sin timeout propio, el workflow se queda colgado ahí. ### Embeddings [#embeddings] Deja **Dimensions vacío**. Callsist aplica 768 por defecto, y si pides una dimensión que el proveedor no devuelve exactamente, la petición falla en vez de guardarte vectores del tamaño equivocado. La dimensión de tu almacén de vectores tiene que ser 768. Callsist **no almacena ni busca vectores**: devuelve los vectores y ahí acaba. El almacén (pgvector, Qdrant, el que sea) lo pones tú, y el RAG en n8n es el patrón normal —embeber, recuperar, meter los fragmentos en el prompt— sin tool calling por medio. *** ## 9. Webhooks en vez de sondeo [#9-webhooks-en-vez-de-sondeo] Solo si n8n está expuesto en HTTPS público. Registro con `POST /v1/webhooks`, la URL de **producción** del nodo Webhook (no la de test, que solo vive mientras miras la pantalla) y `events: ["transcript.completed", "transcript.failed"]`. El `whsec_…` se devuelve una sola vez. Tres cosas que se atascan siempre: **La firma va sobre el cuerpo crudo.** Hay que activar *Options → Raw Body* en el nodo Webhook; sin eso n8n entrega el JSON ya parseado, y el HMAC calculado sobre un objeto re-serializado no cuadra nunca. Se firma `{timestamp}.{cuerpo}` con HMAC-SHA256 y la tolerancia contra reenvío es de 300 s. **El payload no trae la transcripción** —pesaría megabytes y se reenviaría en cada reintento—, solo `transcript_id`, `status`, `language` y `audio_duration`. Al recibirlo, `GET /v1/transcripts/{transcript_id}`. **Responde `2xx` rápido y procesa después.** Nodo *Respond to Webhook* al principio de la rama, no al final. Hay cinco reintentos —1 min, 5 min, 30 min, 2 h, 6 h— y a los cinco fallos el endpoint se desactiva solo; el aviso de que se ha desactivado va por correo, porque el canal que habría que usar es justo el que acaba de fallar. **Para un lote propio, sondear es más simple y más robusto**: no necesitas endpoint público, ni TLS, ni verificar firmas, y una caída de n8n no pierde el aviso — al volver, sondea y ya está. *** ## 10. Del ejemplo al lote [#10-del-ejemplo-al-lote] | Límite | Valor | Qué hacer en n8n | | ------------------------ | ----------------------- | ---------------------------------------- | | Transcripciones en vuelo | **32** por organización | Nodo **Loop Over Items**, *Batch Size* 8 | | Peticiones por minuto | **300 por clave** | Dos claves: una transcribe, otra analiza | | Tamaño de fichero | 2 GB | — | | Duración de audio | 10 h | Trocear antes | Los dos que muerden de verdad: **El sondeo come del mismo cubo que el trabajo.** No hay límite aparte para consultar estado. Sondear 400 transcripciones cada 10 s son 2400 peticiones por minuto: `429` inmediato. Sondea **solo lo que está en vuelo**. Y como el cubo es por clave y no por organización, separar «la clave que transcribe» de «la clave que analiza» es la forma barata de que un lote no ahogue al otro. **El *Retry On Fail* del nodo no lee `Retry-After`.** Espera lo que le pongas en *Wait Between Tries*. Para `429`, `500` y `503` sirve igual poniendo 30 s; para el resto no actives el reintento: `400`, `401`, `403`, `409`, `413`, `415` y `422` no cambian por insistir, y solo gastan cuota. El `402` es aparte — **para el lote y recarga**; seguir mandando peticiones solo llena el log de errores. Guarda `request_id` en tus datos: viene en la cabecera `Callsist-Request-Id` de **toda** respuesta y es lo que se busca en `https://app.callsist.com/logs`. Un último aviso operativo que no es de n8n pero afecta a lo que montes encima: `expires_at` se escribe y **no hay nada que borre lo caducado** (ver [Retención y borrado](/produccion/retencion)). Si necesitas que algo desaparezca de verdad, el workflow tiene que llamar a `DELETE /v1/transcripts/{id}`. *** ## 11. Qué cuesta la prueba [#11-qué-cuesta-la-prueba] Un audio de 30 s con los siete módulos —se factura el minuto empezado: ``` ASR 1 min × 0,39 €/h = 0,0065 € Und. pack 1 min × 0,29 €/h = 0,0048 € ───────── 0,011 € ``` Más el análisis, si lo añades: con `callsist-llm-1-small` y una transcripción corta, unos 0,01 € por ejecución. Poner `GET /v1/balance` como primer nodo del workflow te lo enseña antes y después. *** ## 12. El workflow, de un vistazo [#12-el-workflow-de-un-vistazo] ``` Manual Trigger └─ Read/Write Files from Disk (binario en `data`) └─ HTTP Request POST /v1/files → file_id · sin Idempotency-Key └─ HTTP Request POST /v1/transcripts → tr_… (202) · Idempotency-Key = file_id └─ Wait 15 s ◀────────────────────┐ └─ HTTP Request GET /v1/transcripts/{id} └─ Switch status ├─ completed → Code (warnings, pii, roles) → análisis ├─ failed → manejo de errores └─ resto ─────────┘ (tope con $runIndex) ``` # Redacción de PII (/guias/pii) Se pide dentro de `understanding`: ```json { "pii_redaction": { "entities": ["person", "phone", "nif"], "policy": "entity_name" } } ``` Produce **dos cosas**. En `understanding.pii_redaction`, lo detectado: ```json { "entities": [ { "type": "person", "text": "Marta Ejemplo" }, { "type": "phone", "text": "600 000 000" } ] } ``` Y en la clave `pii` de primer nivel, **el texto ya tapado**: ```json { "policy": "entity_name", "text_redacted": "…me llamo [NOMBRE] y mi teléfono es [TELEFONO]…", "entities": [ "…" ], "utterances_redacted": [ { "speaker": "0", "speakerRole": "customer", "text": "…[NOMBRE]…" } ] } ``` `pii` vale `null` si no se pidió el módulo **o si no se detectó ninguna entidad**. ## La trampa que hay que leer entera [#la-trampa-que-hay-que-leer-entera] **`text` y `utterances` de primer nivel NUNCA se redactan.** Siguen en claro aunque actives la redacción. Es deliberado: quien no pide redacción quiere su transcripción entera. Pero significa que **si la pides y sigues leyendo `text`, estás procesando datos personales en claro creyendo que no**. ```jsonc { "text": "Me llamo Marta Ejemplo y mi DNI es 00000000T", // ⚠ en claro, siempre "pii": { "text_redacted": "Me llamo [NOMBRE] y mi DNI es [DNI]", // ✅ esto es lo que hay que usar "utterances_redacted": [ "…" ] } } ``` **Qué hacer:** en cuanto `pii !== null`, usa `pii.text_redacted` y `pii.utterances_redacted` y no vuelvas a mirar `text` ni `utterances`. ```js const texto = t.pii ? t.pii.text_redacted : t.text; const turnos = t.pii ? t.pii.utterances_redacted : t.utterances; ``` ## Tipos y marcadores [#tipos-y-marcadores] Tipos canónicos (`type`): `person`, `phone`, `email`, `iban`, `card`, `nif`, `address`, `date_of_birth`, `license_plate`, `medical`. El modelo puede contestar «DNI» o «Teléfono» en el idioma de la llamada; se normalizan aquí, y lo que no encaja en la lista se descarta. Marcadores según `policy`: | `policy` | Resultado para «Marta Ejemplo» | | ----------------------- | ---------------------------------------------------------------- | | `entity_name` (defecto) | `[NOMBRE]` | | `hash` | `[NOMBRE_a3f9c2]` — mismo valor, mismo código en toda la llamada | | `mask` | `••••••••••••` | `hash` es el que sirve cuando necesitas saber que dos menciones son la misma persona sin saber quién es: útil para contar clientes distintos en un lote sin guardar nombres. Marcadores por tipo: `[NOMBRE]`, `[TELEFONO]`, `[EMAIL]`, `[CUENTA]`, `[TARJETA]`, `[DNI]`, `[DIRECCION]`, `[FECHA_NACIMIENTO]`, `[MATRICULA]`, `[DATO_MEDICO]`. ## `redact_audio` no silencia el audio [#redact_audio-no-silencia-el-audio] **No pongas `redact_audio: true`.** Factura y **no existe ninguna etapa que silencie el audio**: el fichero vuelve idéntico. Déjalo en `false`, que es el valor por defecto — o mejor, no lo mandes. El silenciado de PII dentro del audio está en camino, pero mientras no funcione no se anuncia ni se debería contratar. # Sondear y leer el resultado (/guias/sondear) `GET /v1/transcripts/{id}`. Scope: `transcripts:read`. ## Ciclo de vida [#ciclo-de-vida] ``` queued ──▶ processing ──▶ completed └─▶ failed ``` Mientras no sea `completed`, la respuesta es corta: ```json { "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 [#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`](/referencia/errores#rate_limit_exceeded) inmediato. ```python 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 [#la-respuesta-completa] Ejemplo real, de una llamada de 217 s con los siete módulos activos: ```jsonc { "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 [#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. ```js 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](/guias/hablantes) y en [Redacción de PII](/guias/pii). ## `warnings` — leerlo siempre [#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:` | Se pidió el módulo y no llegó ningún resultado | | `module_failed:` | El modelo contestó algo que no se pudo validar | | `feature_unavailable:` | 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 `: ` **no son códigos estables**: llevan pegado el texto de la excepción. Compara con `startswith("lid_failed")`, no con igualdad. ## Marcas de tiempo [#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 [#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](/produccion/retencion). # Subir audio (/guias/subir-audio) Hay dos formas de darle audio a Callsist. Con fichero subido la estimación de coste es exacta porque se conoce el tamaño; con URL se asume una duración conservadora. ## `POST /v1/files` [#post-v1files] Scope: `files:write`. `Content-Type: multipart/form-data` con el campo `file`. ```bash curl -X POST https://api.callsist.com/v1/files \ -H "Authorization: Bearer $CALLSIST_API_KEY" \ -F "file=@llamada.mp3" ``` ```json { "id": "file_9xKp2mQvRt4L", "object": "file", "filename": "llamada.mp3", "bytes": 427617, "content_type": "audio/mpeg", "created_at": "2026-07-29T08:12:44.812Z" } ``` * **Formatos**: `wav`, `mp3`, `flac`, `mpga`, `oga`, `ogg`, `m4a`, `mp4`. * **Tamaño máximo**: 2 GB. Se rechaza por `Content-Length` antes de leer el cuerpo. * **Duración máxima**: 10 horas. * **No hay URLs prefirmadas**: el fichero pasa por el dominio de Callsist en streaming. * El `file_id` caduca según la retención por defecto de la organización. Otros métodos: `GET /v1/files/{id}` (requiere `files:write`) y `DELETE /v1/files/{id}`. **No mandes `Idempotency-Key` en `POST /v1/files`.** El middleware hashea el cuerpo para detectar reintentos, y para eso lo convierte en una cadena — incluido un multipart de hasta 2 GB. Mándala en `POST /v1/transcripts`, que es el endpoint que cobra: una subida duplicada solo deja un fichero huérfano, una transcripción duplicada se factura. ## `POST /v1/transcripts` [#post-v1transcripts] Scope: `transcripts:write`. **Es asíncrono**: devuelve `202` con un id. Hay que indicar **exactamente uno** de `audio_url` o `file_id`. Mandar los dos, o ninguno, es [`400 invalid_audio_source`](/referencia/errores#invalid_audio_source). ```jsonc { "audio_url": "https://…/llamada.mp3", // máx. 2048 caracteres, http o https "file_id": "file_9xKp2mQvRt4L", // máx. 64 caracteres "model": "callsist-scribe-1", // único valor válido; es el defecto "language": "auto", // "auto" o ISO-639-1 de dos letras "language_hints": ["es", "ca"], // 1 a 10 códigos. MUY recomendable "punctuate": true, "format_text": true, "custom_vocabulary": ["Fibra Óptica"], // hasta 1000, 80 caracteres cada uno "understanding": { /* los siete módulos */ }, "priority": "standard", // "standard" | "economy" "metadata": { "ticket": "T-91823" }, "retention": "30d" // "none" | "24h" | "7d" | "30d" } ``` ### `language_hints` es lo que más mejora la detección [#language_hints-es-lo-que-más-mejora-la-detección] Pon solo los idiomas que de verdad aparecen en tus llamadas. Quitar al italiano de la competición mejora sensiblemente la discriminación entre castellano y catalán, que es la confusión más frecuente en llamadas peninsulares. ### `metadata` es la pieza que hace útil la integración [#metadata-es-la-pieza-que-hace-útil-la-integración] Diccionario de hasta 64 caracteres por clave y 512 por valor. Vuelve tal cual en `GET /v1/transcripts/{id}` y en el listado. **Ahí es donde va el id del cliente, el del agente y el del ticket del CRM**: es lo que permite cruzar la transcripción con el resto de tus datos sin mantener una tabla de correspondencias aparte. ### `retention` explícito, aunque coincida con tu defecto [#retention-explícito-aunque-coincida-con-tu-defecto] No porque haga falta, sino porque deja la intención escrita en la petición y no en una configuración que alguien puede cambiar sin avisar. ## Cinco parámetros que se aceptan y no hacen nada [#cinco-parámetros-que-se-aceptan-y-no-hacen-nada] Pasan la validación, no dan error y **no cambian el resultado**. El pipeline solo recibe `language`, `language_hints`, `custom_vocabulary`, `punctuate` y `format_text`. | Parámetro | Lo que parece | Lo que pasa | | ------------------------------- | ----------------------------- | ------------------------------------------------ | | `speaker_labels: false` | Desactivar la diarización | Se diariza igual | | `speakers_expected: 2` | Ayudar al diarizador | Se ignora; el número de hablantes se decide solo | | `channels: "multichannel"` | Separar por canal | Se ignora; siempre se mezcla a mono | | `word_timestamps: false` | No devolver `words` | Vienen igual | | `language_confidence_threshold` | Umbral del detector de idioma | Se ignora | **No construyas lógica que dependa de ellos.** Para grabaciones de contact center no suele importar —son mono y la diarización acústica funciona—, pero si tienes estéreo con agente y cliente en canales separados, hoy no se aprovecha: se avisa con `stereo_source_available` en `warnings`. ## Idempotencia [#idempotencia] Manda `Idempotency-Key: ` en el `POST`. Durante 24 h: * **misma clave, mismo cuerpo** → se devuelve la respuesta original, con la cabecera `Idempotency-Replayed: true`, y **no se cobra otra vez**; * **misma clave, cuerpo distinto** → [`409 idempotency_key_reused`](/referencia/errores#idempotency_key_reused). Úsala siempre. Un reintento de red sin ella crea una segunda transcripción y la cobra. Un buen valor es el `file_id`, o el id de la llamada en tu sistema. # Webhooks (/guias/webhooks) **`webhook_url` en `POST /v1/transcripts` no funciona.** `webhook_url`, `webhook_auth_header` y `webhook_include_result` se rechazan con [`400`](/referencia/errores#invalid_webhook_url), con un mensaje que apunta aquí. Hasta hace poco se aceptaban, devolvían `202` y no los leía nadie: el aviso no salía nunca y el cliente lo daba por configurado. Aceptar algo que no se hace es peor que no aceptarlo. ## Registrar — `POST /v1/webhooks` [#registrar--post-v1webhooks] Scope: `transcripts:write`. ```json { "url": "https://mi-app.example.com/callsist", "events": ["transcript.completed", "transcript.failed"] } ``` Solo `https`. La respuesta trae el `secret` (`whsec_…`) **una sola vez**. | Evento | Cuándo llega | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `transcript.completed` | La transcripción está lista para recoger | | `transcript.failed` | No se pudo procesar. **No se factura** | | `credit.low` | El saldo bajó del umbral configurado en el panel. Una vez por cruce | | `credit.exhausted` | El saldo llegó a cero: desde ese momento todo devuelve `402` | | `webhook.disabled` | Un endpoint tuyo se apagó tras cinco fallos. **No se envía por webhook** — el canal que habría que usar es el que acaba de fallar. Va por correo | | `api_key.created` | Declarado y todavía sin emisor | Los dos de crédito llegan **también por correo** a quien tenga el rol de propietario o de facturación, y ahí está el aviso que de verdad evita el susto. El umbral se configura en [app.callsist.com/billing](https://app.callsist.com/billing) y se manda una sola vez al cruzar hacia abajo, así que un lote de cuatrocientas llamadas por debajo del umbral produce un correo, no cuatrocientos. Otros métodos: `GET /v1/webhooks`, `POST /v1/webhooks/{id}/enable`, `DELETE /v1/webhooks/{id}`. ## El payload no trae la transcripción [#el-payload-no-trae-la-transcripción] Puede pesar megabytes y se reenviaría en cada reintento. ```json { "transcript_id": "tr_MoKLFufBqnzJ", "status": "completed", "language": "es", "audio_duration": 216.999 } ``` Al recibirlo, haz `GET /v1/transcripts/{transcript_id}`. ## Verificar la firma [#verificar-la-firma] ``` Callsist-Signature: t=1785312782,v1=5f2a9c… ``` Se firma `{timestamp}.{cuerpo}` con HMAC-SHA256 y el secreto. Tolerancia contra reenvío: **300 segundos**. ```python import hmac, hashlib, time def verificar(cuerpo: bytes, cabecera: str, secreto: str) -> bool: partes = dict(p.split("=", 1) for p in cabecera.split(",")) t, recibida = int(partes["t"]), partes["v1"] if abs(time.time() - t) > 300: return False esperada = hmac.new( secreto.encode(), f"{t}.".encode() + cuerpo, hashlib.sha256 ).hexdigest() return hmac.compare_digest(esperada, recibida) ``` **Firma sobre el cuerpo crudo**, antes de parsear el JSON. Si tu framework te entrega el objeto ya parseado y lo vuelves a serializar, el HMAC no cuadra nunca: cambia el orden de las claves, los espacios o el escapado. ## Reintentos [#reintentos] Cinco: **1 min, 5 min, 30 min, 2 h y 6 h**. Tras varios fallos consecutivos el endpoint se desactiva y se emite `webhook.disabled`. **Responde `2xx` rápido y procesa después.** ## Cuándo no usar webhooks [#cuándo-no-usar-webhooks] Para un lote de 400 llamadas propio, **sondear es más simple y más robusto**: no necesitas endpoint público, ni TLS, ni verificar firmas, y un fallo tuyo no pierde el aviso — al volver, sondeas y ya está. Ver [Sondear](/guias/sondear). Los webhooks ganan cuando el volumen es continuo y bajo, cuando la latencia importa, o cuando ya tienes un endpoint público que recibe eventos de otros proveedores. # Retención y borrado (/produccion/retencion) `retention` en `POST /v1/transcripts` admite `"none"`, `"24h"`, `"7d"` y `"30d"`. Si no lo mandas, se aplica la política de la organización, que ves en `default_retention` de [`GET /v1/me`](/empezar/autenticacion). Mandarlo explícito sigue siendo lo recomendable: no porque haga falta, sino porque deja la intención escrita en la petición y no en una configuración que alguien puede cambiar sin avisar. ## Nada borra lo caducado [#nada-borra-lo-caducado] `expires_at` se escribe en transcripciones, ficheros y objetos, y **no existe ningún proceso que lo lea**. Nada se borra al caducar. Es lo contrario de un problema para unas pruebas —tus datos siguen ahí— y es un problema de cumplimiento el día que un cliente exija que «30 días» signifique algo. **Qué hacer:** si necesitas que algo desaparezca, llama a `DELETE /v1/transcripts/{id}`, que sí borra de verdad: la fila, los fragmentos, los vectores y el audio del almacenamiento. No es reversible. Para un encargado del tratamiento con un contrato que promete plazos de conservación, esto significa que **el borrado lo tiene que provocar tu sistema**, con un trabajo programado que recorra lo que ya no debe estar y lo borre. No lo va a hacer la retención por sí sola. ## Qué se guarda [#qué-se-guarda] | Qué | Dónde | Lo borra | | ----------------------------- | ------------------------- | -------------------------------------------------------- | | El audio subido | Almacenamiento de objetos | `DELETE /v1/files/{id}` o el borrado de la transcripción | | La transcripción y sus turnos | Base de datos | `DELETE /v1/transcripts/{id}` | | Los fragmentos y vectores | Base de datos | `DELETE /v1/transcripts/{id}` | | `metadata` | Junto a la transcripción | Idem | `metadata` merece una nota: **vuelve tal cual en el listado y en la lectura**, así que lo que metas ahí se guarda tanto como la transcripción. Es el sitio del id del ticket y del agente; no es el sitio del nombre del cliente ni de su DNI. ## Y el audio [#y-el-audio] Si activaste `pii_redaction`, recuerda que la redacción es **sobre el texto**: el audio original sigue guardado sin tocar, con la voz y los datos que se dijeran en voz alta. El silenciado dentro del audio no funciona hoy —ver [Trampas](/produccion/trampas)—, así que la única forma de que ese audio no esté es no subirlo o borrarlo después. # Trampas conocidas (/produccion/trampas) Todo lo de aquí está **verificado leyendo el código**, no deducido. Son cosas que la API acepta sin protestar y que no hacen lo que su nombre promete. Están ordenadas por lo que cuesta descubrirlas tarde. ## 1. No hay webhook por petición [#1-no-hay-webhook-por-petición] ```jsonc { "audio_url": "…", "webhook_url": "https://mi-app/callsist" } // ⛔ 400 ``` `webhook_url`, `webhook_auth_header` y `webhook_include_result` **se rechazan con [`400`](/referencia/errores#invalid_webhook_url)**, con un mensaje que apunta a `POST /v1/webhooks`. Hasta hace poco se aceptaban, devolvían `202` y no los leía nadie: el aviso no salía nunca y el cliente lo daba por configurado. Aceptar algo que no se hace es peor que no aceptarlo. **Qué hacer:** registrar un endpoint, o [sondear](/guias/sondear). Para un lote propio, sondear es más simple. ## 2. Con redacción de PII, el texto de primer nivel sigue en claro [#2-con-redacción-de-pii-el-texto-de-primer-nivel-sigue-en-claro] `text` y `utterances` **nunca** se redactan. La versión tapada vive en la clave `pii`. Es deliberado —quien no pide redacción quiere su transcripción entera— pero **si la pides y sigues leyendo `text`, estás procesando datos personales en claro creyendo que no**. **Qué hacer:** en cuanto `pii !== null`, usa `pii.text_redacted` y `pii.utterances_redacted`. Detalles en [Redacción de PII](/guias/pii). ## 3. `redact_audio: true` se cobra y no silencia nada [#3-redact_audio-true-se-cobra-y-no-silencia-nada] Factura y **no existe ninguna etapa que silencie el audio**. El fichero vuelve idéntico. **Qué hacer:** déjalo en `false`, que es el valor por defecto. No lo mandes. ## 4. Cinco parámetros se aceptan y se ignoran en silencio [#4-cinco-parámetros-se-aceptan-y-se-ignoran-en-silencio] El pipeline solo recibe `language`, `language_hints`, `custom_vocabulary`, `punctuate` y `format_text`. `speaker_labels`, `speakers_expected`, `channels`, `word_timestamps` y `language_confidence_threshold` se quedan en el camino, **sin warning**. **Qué hacer:** no construyas lógica que dependa de ellos. La tabla completa está en [Subir audio](/guias/subir-audio#cinco-parámetros-que-se-aceptan-y-no-hacen-nada). ## 5. `Idempotency-Key` en `POST /v1/files` lee el fichero entero en memoria [#5-idempotency-key-en-post-v1files-lee-el-fichero-entero-en-memoria] El middleware hashea el cuerpo para detectar reintentos, y para eso lo convierte en una cadena — incluido un multipart de hasta 2 GB. **Qué hacer:** mándala en `POST /v1/transcripts`, que es el que cobra, y **no** en `POST /v1/files`. Una subida duplicada solo deja un fichero huérfano. ## 6. `Idempotency-Key` con `stream: true` mata el streaming [#6-idempotency-key-con-stream-true-mata-el-streaming] El middleware consume la respuesta entera para memorizarla. Llega completa de golpe al final. **Qué hacer:** no los combines. ## 7. El saldo puede quedarse en negativo por chat y embeddings [#7-el-saldo-puede-quedarse-en-negativo-por-chat-y-embeddings] `POST /v1/transcripts` estima el coste y devuelve [`402`](/referencia/errores#insufficient_credit) **antes** de procesar. Los otros dos cobran **después** de responder, así que no pueden reservar: se niegan a empezar con el saldo a cero o menos, pero una llamada en curso puede dejarlo en negativo. En la práctica el descubierto es de céntimos, no de euros. **Qué hacer:** configura el aviso de saldo bajo en [app.callsist.com/billing](https://app.callsist.com/billing), o consulta `GET /v1/balance` cada cierto número de llamadas. ## 8. Una llamada de chat puede tardar ocho minutos antes de fallar [#8-una-llamada-de-chat-puede-tardar-ocho-minutos-antes-de-fallar] El cliente HTTP interno tiene 120 s de timeout y hasta 3 reintentos con espera creciente. En el peor caso una petición tarda varios minutos en devolver un error, y **no hay forma de acotarlo desde la petición**. **Qué hacer:** pon tu propio timeout de cliente (60–120 s para análisis por lotes) y trátalo como reintentable. ## 9. `estimated_completion` es siempre «ahora + 6 minutos» [#9-estimated_completion-es-siempre-ahora--6-minutos] Es una constante. No mira la cola ni la duración del audio. **Qué hacer:** no lo uses como timeout ni como predicción. Sondea. ## 10. El sondeo comparte el cubo de 300 peticiones por minuto [#10-el-sondeo-comparte-el-cubo-de-300-peticiones-por-minuto] No hay límite aparte para consultar estado. Sondear 400 transcripciones cada 10 segundos son 2 400 peticiones por minuto: `429` inmediato. **Qué hacer:** sondea **solo lo que está en vuelo**. Con 8 en curso y un sondeo cada 10 s son 48 peticiones por minuto. ## 11. `speakerRole` está en camelCase [#11-speakerrole-está-en-camelcase] En un JSON donde todo lo demás es `snake_case`, dentro de `utterances[]` y de `pii.utterances_redacted[]` el campo se llama `speakerRole`. **Qué hacer:** escribirlo bien la primera vez. ## 12. `metrics.telemetry` no es contrato [#12-metricstelemetry-no-es-contrato] Trae instrumentación interna —tiempos y memoria por etapa— y **cambia sin aviso**. De `metrics`, lo estable es `timestamps` y `speaker_role_confidence`. ## 13. `understanding` es `{}`, no `null`, cuando no pediste nada [#13-understanding-es--no-null-cuando-no-pediste-nada] **Qué hacer:** comprueba las claves que esperas, no si el objeto existe. ## 14. `GET /v1/transcripts` pagina por cursor, y el cursor lleva microsegundos [#14-get-v1transcripts-pagina-por-cursor-y-el-cursor-lleva-microsegundos] Pasa `next_before` **tal cual**. 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. ## 15. Cosas que existen en la taxonomía y no ocurren nunca [#15-cosas-que-existen-en-la-taxonomía-y-no-ocurren-nunca] * El estado `cancelled` se puede filtrar y **nadie lo produce**: no hay endpoint de cancelación. * **Trece códigos de error están publicados y no se emiten jamás.** Están marcados uno a uno en [Errores](/referencia/errores). Maneja los errores por familia de `type` y no des por hecho que verás cada código. # Endpoints (/referencia/endpoints) Los cuerpos de petición de esta página **se generan desde los esquemas de validación de la API**, no están escritos a mano. Lo que ves es literalmente lo que se acepta. Lo que sí está escrito a mano —y por tanto puede quedarse corto— son las descripciones y los códigos de respuesta. Si algo no cuadra, manda el comportamiento de la API. **Aceptado no significa atendido.** Como los cuerpos salen del esquema de validación, aquí aparecen también los campos que la API acepta y luego **ignora** — `speaker_labels`, `speakers_expected`, `channels`, `word_timestamps` y `language_confidence_threshold`. Pasan la validación, no dan error y no cambian el resultado: la lista, con lo que hace cada uno de verdad, está en [Subir audio](/guias/subir-audio#cinco-parámetros-que-se-aceptan-y-no-hacen-nada). **No hay consola de pruebas, y es deliberado.** Ver [por qué](#por-qué-no-hay-try-it). ## Por qué no hay «try it» [#por-qué-no-hay-try-it] Deepgram y AssemblyAI tienen consola de pruebas en su referencia. Nosotros no, y no es por falta de ganas. El CORS de la API es una **lista blanca** con un solo origen: el panel. Poner una consola aquí obligaría a añadir `docs.callsist.com`, y eso significa invitar a pegar una clave `sk_live_` en una página web pública, donde la lee cualquiera que abra el inspector del navegador. La clave no caduca y da acceso a transcribir, a gastar saldo y a leer todo lo transcrito. El playground autenticado del panel hace lo mismo sin ese problema, porque allí la sesión ya existe y la clave no tiene que viajar a ninguna parte: [app.callsist.com](https://app.callsist.com). Para probar desde tu máquina, copia el `curl` de cada operación. # Errores (/referencia/errores) Toda respuesta de error tiene la misma forma: ```json { "error": { "type": "invalid_request_error", "code": "invalid_parameter", "message": "…", "param": "understanding.sentiment_analysis.scope", "doc_url": "https://docs.callsist.com/referencia/errores#invalid_parameter", "request_id": "req_9xKp2mQvRt4L" } } ``` **Programa contra `code`, nunca contra `message`.** Los códigos son estables para siempre: añadir uno es libre, renombrar o reutilizar uno es un cambio rompedor. Los mensajes cambian sin aviso y están en castellano. Guarda `request_id`. Es el mismo que viaja en la cabecera `Callsist-Request-Id` de **toda** respuesta y el que aparece en [app.callsist.com/logs](https://app.callsist.com/logs). Pegarlo ahí es la diferencia entre depurar en un minuto y abrir un ticket. ## Qué reintentar [#qué-reintentar] | | Códigos | Qué hacer | | -------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------- | | **Reintentables** | `429`, `500`, `503` | Espera exponencial con jitter, respetando `Retry-After` si viene | | **No reintentables** | `400`, `401`, `403`, `404`, `409`, `413`, `415`, `422` | Reintentar gasta cuota y no cambia el resultado | | **Caso aparte** | `402` | **Para el lote entero**, avisa y recarga. Seguir mandando solo llena los logs | **Trece códigos están publicados y no se emiten jamás.** Es deliberado: la taxonomía cubre casos que el pipeline todavía no distingue. Los que se sabe que no llegan están marcados abajo con «no se emite». Maneja los errores por familia de `type` y no des por hecho que verás cada código de esta lista. *** ## 400 · `invalid_request_error` [#400--invalid_request_error] La petición está mal. **No reintentar** sin cambiarla. ### `invalid_parameter` [#invalid_parameter] Un campo tiene un valor que no se admite. `param` dice cuál, con notación de punto para los anidados: `understanding.sentiment_analysis.scope`. ### `missing_parameter` [#missing_parameter] Falta un campo obligatorio. El caso más frecuente es subir un fichero sin el campo de formulario `file` en `POST /v1/files`: el nombre no es negociable. ### `invalid_audio_source` [#invalid_audio_source] Hay que indicar **exactamente uno** de `audio_url` o `file_id` en `POST /v1/transcripts`. Mandar los dos, o ninguno, cae aquí. ### `audio_duration_exceeded` [#audio_duration_exceeded] Más de 10 horas de audio. Trocéalo antes de enviarlo. ### `unsupported_language` [#unsupported_language] El código de idioma no está en el catálogo. `language` admite `"auto"` o un ISO-639-1 de dos letras. ### `sentiment_scope_not_permitted` [#sentiment_scope_not_permitted] `understanding.sentiment_analysis.scope` solo admite `"customer"`. Mandar `"agent"` cae aquí, y **no es un bug pendiente de arreglar**: el Reglamento de IA (art. 5.1.f) prohíbe inferir emociones de trabajadores en su entorno laboral, y vincula también al proveedor del sistema. Esto no impide evaluar la calidad del trabajo de un agente contra una plantilla — eso es evaluación de desempeño sobre hechos observables. Lo que no se puede es preguntar «¿cómo se sentía el agente?». ### `invalid_model` [#invalid_model] El identificador de modelo no existe. Consulta [`GET /v1/models`](/referencia/modelos), que es público y sin autenticación. La respuesta **no repite el ID que mandaste**. ### `invalid_webhook_url` [#invalid_webhook_url] Solo `https` en `POST /v1/webhooks`. También cae aquí mandar `webhook_url`, `webhook_auth_header` o `webhook_include_result` dentro de `POST /v1/transcripts`: **no existe el webhook por petición**. Hasta hace poco se aceptaban en silencio, devolvían `202` y no los leía nadie. Ver [Webhooks](/guias/webhooks). *** ## 401 · `authentication_error` [#401--authentication_error] ### `missing_api_key` [#missing_api_key] Falta la cabecera `Authorization`. ### `invalid_api_key` [#invalid_api_key] La clave no tiene la forma esperada o no existe. Empiezan por `sk_live_` seguido de 32 caracteres alfanuméricos; una clave `sk_test_` se rechaza por forma, porque el modo de prueba se retiró. ### `revoked_api_key` [#revoked_api_key] La clave se revocó. Ojo con el margen: **una clave revocada sigue funcionando hasta 30 segundos**, que es lo que dura la caché de autenticación. ### `expired_api_key` [#expired_api_key] Declarado, **no se emite**: hoy las claves no caducan solas. *** ## 402 · `insufficient_credit` [#402--insufficient_credit] ### `insufficient_credit` [#insufficient_credit] No hay saldo. `POST /v1/transcripts` estima el coste y devuelve esto **antes** de procesar, así que no se gasta nada. **Para el lote y recarga.** Reintentar no arregla nada. Configura el aviso de saldo bajo en [app.callsist.com/billing](https://app.callsist.com/billing): se manda una sola vez al cruzar el umbral hacia abajo, así que un lote de cuatrocientas llamadas por debajo del umbral produce un correo, no cuatrocientos. ### `spend_cap_reached` [#spend_cap_reached] Declarado, **no se emite** todavía. *** ## 403 · `permission_error` [#403--permission_error] ### `missing_scope` [#missing_scope] La clave no tiene el permiso que la ruta exige. `param` dice cuál falta. **También es lo que sale si tu IP está fuera de la lista blanca de la clave**, con `param: "ip_allowlist"`. Es el error más desconcertante de la API: si una clave que funcionaba deja de funcionar desde una máquina nueva, mira la lista blanca antes que los scopes. ### `ip_not_allowed` [#ip_not_allowed] Declarado, **no se emite nunca**. El caso que describe sale como `missing_scope` con `param: "ip_allowlist"`. *** ## 404 · `not_found_error` [#404--not_found_error] ### `resource_not_found` [#resource_not_found] El recurso o el modelo no existen. Vale también para una transcripción de otra organización: no se distingue «no existe» de «no es tuya», a propósito. *** ## 409 · `conflict_error` [#409--conflict_error] ### `idempotency_key_reused` [#idempotency_key_reused] Misma `Idempotency-Key`, cuerpo distinto, dentro de la ventana de 24 h. Con la misma clave y el mismo cuerpo se devuelve la respuesta original y la cabecera `Idempotency-Replayed: true`, sin cobrar de nuevo. *** ## 413 · `payload_too_large` [#413--payload_too_large] ### `file_too_large` [#file_too_large] Más de 2 GB. Se rechaza por `Content-Length` **antes** de leer el cuerpo. *** ## 415 · `unsupported_media_type` [#415--unsupported_media_type] ### `unsupported_audio_format` [#unsupported_audio_format] Formatos admitidos: `wav`, `mp3`, `flac`, `mpga`, `oga`, `ogg`, `m4a`, `mp4`. *** ## 422 · `unprocessable_audio` [#422--unprocessable_audio] Los tres están declarados y **ninguno se emite hoy**: un audio inservible acaba en una transcripción con `status: "failed"` o con el aviso `asr_no_segments`, no en un `422`. Están documentados porque el código puede empezar a emitirlos sin ser un cambio rompedor. ### `audio_corrupt` [#audio_corrupt] El fichero no se puede decodificar. **No reintentar.** ### `audio_silent` [#audio_silent] No hay voz en el audio. ### `audio_too_short` [#audio_too_short] Demasiado corto para transcribir. *** ## 429 · `rate_limit_error` [#429--rate_limit_error] ### `rate_limit_exceeded` [#rate_limit_exceeded] Más de **300 peticiones por minuto y por clave**. Espera los segundos de `Retry-After`. El cubo es por clave, no por organización: dos claves distintas son dos cubos. Y el sondeo de estado consume del mismo cubo que las llamadas de negocio si van con la misma clave. Ver [Lotes y límites](/guias/lotes). ### `concurrency_limit_exceeded` [#concurrency_limit_exceeded] Más de **32 transcripciones en vuelo** por organización — todo lo que esté en `queued` o `processing`. Viene con `Retry-After: 30`. *** ## 500 · `api_error` [#500--api_error] ### `internal_error` [#internal_error] Fallo nuestro. Reintentar con espera creciente. **No se factura.** *** ## 503 · `service_unavailable` [#503--service_unavailable] ### `upstream_unavailable` [#upstream_unavailable] Una dependencia no responde. Reintentar con espera creciente. **No se factura.** ### `queue_saturated` [#queue_saturated] Declarado, **no se emite**. La saturación se manifiesta como espera en la cola, no como error. # Límites y cuotas (/referencia/limites) | Límite | Valor | Qué pasa al superarlo | | ------------------------ | -------------------------------------- | -------------------------------------------------------------------------------------- | | Peticiones por minuto | **300 por CLAVE** | [`429 rate_limit_exceeded`](/referencia/errores#rate_limit_exceeded) con `Retry-After` | | Transcripciones en vuelo | **32** por organización | [`429 concurrency_limit_exceeded`](/referencia/errores#concurrency_limit_exceeded) | | Tamaño de fichero | 2 GB | [`413`](/referencia/errores#file_too_large) | | Duración de audio | 10 h | [`400 audio_duration_exceeded`](/referencia/errores#audio_duration_exceeded) | | `custom_vocabulary` | 1 000 términos, 80 caracteres cada uno | `400` | | `input` de embeddings | 256 elementos | `400` | | `max_tokens` de chat | 32 000 | `400` | | Ventana de idempotencia | 24 h | [`409`](/referencia/errores#idempotency_key_reused) si cambia el cuerpo | Cabeceras en toda respuesta autenticada: ``` X-RateLimit-Limit: 300 X-RateLimit-Remaining: 287 X-RateLimit-Reset: 0 Callsist-Request-Id: req_9xKp2mQvRt4L ``` ## Dos detalles que cambian cómo se programa contra el límite [#dos-detalles-que-cambian-cómo-se-programa-contra-el-límite] **Es por clave, no por organización.** Es una ventana deslizante de 60 s sobre el identificador de la clave. Dos claves distintas tienen dos cubos distintos: si necesitas más caudal, una segunda clave lo duplica. Y al revés — el sondeo de estado consume del mismo cubo que las llamadas de negocio si van con la misma clave. **Separar «la clave que transcribe» de «la clave que analiza» es la forma barata de que un lote no ahogue al otro.** **`X-RateLimit-Reset` vale `0` mientras la petición se permite.** Solo dice algo real en el `429`, donde además viene `Retry-After` con el mismo número. Para frenar antes de chocar, mira `X-RateLimit-Remaining` y espera un tiempo fijo; usar `Reset` sería esperar cero segundos. ## El techo de 32 en vuelo es el que gobierna un lote [#el-techo-de-32-en-vuelo-es-el-que-gobierna-un-lote] Cuenta como «en vuelo» todo lo que esté en `queued` o `processing`. Con una cola propia de 8 simultáneas nunca se toca; con 40 sí. Y el sondeo tiene su propia trampa: no hay límite aparte para consultar estado, así que sondear 400 transcripciones cada 10 segundos son 2 400 peticiones por minuto y un `429` inmediato. **Sondea solo lo que está en vuelo.** Con 8 en curso y un sondeo cada 10 s son 48 peticiones por minuto, que caben de sobra. Ver [Lotes y límites](/guias/lotes). ## Timeouts [#timeouts] `POST /v1/chat/completions` puede tardar **varios minutos** en devolver un error: el cliente HTTP interno tiene 120 s de timeout y hasta 3 reintentos con espera creciente, y no hay forma de acotarlo desde la petición. Pon tu propio timeout de cliente —60 a 120 s para análisis por lotes— y trátalo como reintentable. `estimated_completion` en la respuesta de `POST /v1/transcripts` **no es un timeout**: es siempre «ahora + 6 minutos», una constante que no mira ni la cola ni la duración del audio. # Modelos (/referencia/modelos) `GET /v1/models` es **público y sin autenticación**. Devuelve los cuatro identificadores utilizables: ```bash curl https://api.callsist.com/v1/models ``` | ID | Tipo | Ventana | Para qué | | ---------------------- | ----------- | ------- | --------------------------------- | | `callsist-scribe-1` | `asr` | — | Transcripción | | `callsist-llm-1-large` | `llm` | 250 000 | Análisis complejo, contexto largo | | `callsist-llm-1-small` | `llm` | 256 000 | Clasificación, extracción, lotes | | `callsist-embed-1` | `embedding` | 32 000 | Embeddings multilingües | **Nunca uses un identificador que no esté en esta lista.** Uno desconocido devuelve [`404`](/referencia/errores#resource_not_found) o [`400 invalid_model`](/referencia/errores#invalid_model) según la ruta, y la respuesta **no repite el ID que mandaste**, así que un error de tecleo cuesta más de encontrar de lo que parece. ## Qué son estos modelos [#qué-son-estos-modelos] `callsist-scribe-1` no es un modelo suelto: es **nuestro motor de transcripción** —preproceso, detección de voz, identificación de idioma restringida, troceado, alineación, diarización, atribución de rol y detección de PII—. El identificador nombra el pipeline completo, que es lo que se contrata y lo que se factura. Los de LLM y embeddings son la puerta a modelos generalistas alojados en la UE, con la misma factura y el mismo control de acceso que el resto de la API. ## Elegir entre `small` y `large` [#elegir-entre-small-y-large] | | `callsist-llm-1-small` | `callsist-llm-1-large` | | ------- | ------------------------------------------------ | ------------------------------------------------ | | Entrada | 0,65 €/M tokens | 1,50 €/M tokens | | Salida | 3,75 €/M tokens | 8,00 €/M tokens | | Ventana | 256 000 | 250 000 | `small` es el correcto para extracción y clasificación sobre texto dado —que es lo que es evaluar una plantilla sobre una transcripción—. Reserva `large` para razonamiento comparativo real: cuesta más del doble en entrada y en salida. Fíjate en que la ventana de `small` es **mayor** que la de `large`. No es una errata: son modelos distintos, no dos tamaños del mismo. Si lo que te limita es el contexto y no el razonamiento, `small` es además el que más cabe. ## Verificarlo tú [#verificarlo-tú] Esta tabla se comprueba en cada build contra el `GET /v1/models` de producción. Si alguna vez no coincide, manda la API: ```bash curl -s https://api.callsist.com/v1/models | jq '.data[].id' ``` # Precios (/referencia/precios) Todo se descuenta de un saldo prepago. **Los fallos del servicio no se facturan, y un módulo de comprensión que se degrada tampoco.** ## Transcripción — por minutos empezados, mínimo uno [#transcripción--por-minutos-empezados-mínimo-uno] La unidad de facturación es el **minuto, con mínimo de un minuto**. Un audio de 30 segundos factura 60 segundos; uno de 3 min 10 s factura 4 minutos. | SKU | €/hora | | ---------------------------------------------- | ------------------------------------- | | `asr.scribe1` | 0,39 | | `und.pack` — los siete módulos | 0,29 | | `und.pack.economy` — con `priority: "economy"` | 0,22 | ### Módulos sueltos [#módulos-sueltos] Si no se piden los siete: | SKU | Módulo | €/hora | | ---------------- | --------------------------- | ------------------------------------- | | `und.topics` | Detección de temas | 0,06 | | `und.moderation` | Moderación de contenido | 0,06 | | `und.pii` | Redacción de PII | 0,09 | | `und.profanity` | Filtro de lenguaje ofensivo | 0,02 | | `und.sentiment` | Sentimiento del cliente | 0,05 | | `und.summary` | Resumen | 0,07 | | `und.actions` | Tareas y próximos pasos | 0,06 | **Pedir los siete es más barato que pedir seis.** El pack se aplica solo si están los siete: 0,29 €/h. Con seis se cobran sueltos, y seis sueltos salen a 0,35 €/h. ### Descuentos por volumen [#descuentos-por-volumen] Automáticos, sobre las horas transcritas en el mes: | Desde | €/hora | | -------- | ------------------------------------- | | 0 h | 0,39 | | 1 000 h | 0,33 | | 10 000 h | 0,26 | ## LLM y embeddings — por token [#llm-y-embeddings--por-token] | SKU | €/M tokens | | --------------- | ------------------------------------- | | `llm.small.in` | 0,65 | | `llm.small.out` | 3,75 | | `llm.large.in` | 1,50 | | `llm.large.out` | 8,00 | | `embed.1` | 0,28 | Reembeber una base de conocimiento de un millón de tokens cuesta 0,28 €. No es ahí donde está el gasto. ## Cuánto cuesta de verdad [#cuánto-cuesta-de-verdad] Una llamada de 3,5 minutos —medido sobre grabaciones reales de BPO— con transcripción y los siete módulos: ``` ASR 4 min × 0,39 €/h = 0,026 € Und. pack 4 min × 0,29 €/h = 0,019 € ─────── 0,045 € ``` Añadiendo un análisis con plantilla sobre `callsist-llm-1-small`, con unos 25 000 tokens de entrada —transcripción, tickets y procedimientos— y 4 000 de salida: ``` llm.small.in 25 000 × 0,65 €/M = 0,016 € llm.small.out 4 000 × 3,75 €/M = 0,015 € ─────── Total por llamada 0,076 € Total 400 llamadas 30,50 € ``` Con `callsist-llm-1-large` el análisis pasa de 0,031 € a 0,070 € por llamada, y las 400 llamadas de 30,50 € a 46,00 €. Para extracción y clasificación sobre texto dado, `small` es el correcto; reserva `large` para razonamiento comparativo real. ## Qué no se factura [#qué-no-se-factura] * Un `500` o un `503`: fallo nuestro. * Una transcripción que acaba en `status: "failed"`. * Un módulo de comprensión que aparece en `warnings` — se pidió y no llegó. * `GET /v1/me`, `GET /v1/balance` y `GET /v1/models`. * Una petición replicada por `Idempotency-Key` con el mismo cuerpo.