# 15 · Referencia de las operaciones

Las ocho operaciones de `/v1`, con su permiso, su unidad de cobro y su contrato de entrada. La forma del
sobre y de los errores está en las páginas [14](./14-api-quickstart.md) y [16](./16-api-errors.md).

Todos los nombres de campo van en `snake_case`, igual que el sobre de uso y el cuerpo de error.

## Resumen

| Operación | Permiso | Se factura como | Unidad | Respuesta |
|---|---|---|---|---|
| `POST /v1/vision/analysis` | `vision:analyze` | `image_analysis` | imagen | `200` con sobre |
| `POST /v1/documents/analysis` | `documents:analyze` | `document_analysis` | documento | `200` con sobre |
| `POST /v1/video/analysis` | `video:analyze` | `video_analysis` | video | `200` con sobre |
| `POST /v1/audio/transcriptions` | `audio:transcribe` | `audio_transcription` | segundo | `200` con sobre |
| `POST /v1/audio/speech` | `speech:synthesize` | `tts_synthesis` | carácter | `200` **binario** |
| `POST /v1/text/sentiment` | `sentiment:analyze` | `sentiment_analysis` | petición | `200` con sobre |
| `POST /v1/calls` | `calls:initiate` | `voice_call_minute` | minuto | `202` con acuse |
| `POST /v1/agent/turns` | `agent:converse` | `agent_turn` | petición | `202` con acuse |
| `GET /v1/jobs/{job_id}` | `jobs:read` | no se cobra | — | `200` ([página 17](./17-jobs.md)) |
| `GET /v1/agents` | `agents:read` | no se cobra | — | `200` con la lista de sus agentes activos |

Las seis primeras responden con el resultado en la misma llamada. Las dos asíncronas —la llamada y el turno—
responden un acuse y el resultado llega después. Las dos últimas son LECTURAS y no cobran nada.

Hay una entrada más que no aparece arriba porque no es una operación que se pida, sino la lectura de una que
ya pidió: el stream del turno, en `GET /v1/agent/streams/{job_id}`, cuya URL exacta le llega en el acuse. Lo
autoriza `jobs:read`, y su contrato está en la [página 19](./19-api-stream.md).

### De dónde sale el `agent_id`

`POST /v1/agent/turns` y `POST /v1/calls` exigen un `agent_id`, y `GET /v1/agents` es el que se lo da: devuelve
sus agentes **activos** con `id`, `name` y `active`. Nada más — la configuración del agente (capacidades,
reglas, canales, voz) se gobierna en la consola y no viaja por esta API.

**cURL**

```bash
curl -sS -X GET "$ASIXTO_BASE_URL/v1/agents" \
  -H "Authorization: Bearer $ASIXTO_API_KEY"
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/agents`, {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
  },
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/agents"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
}

respuesta = requests.get(url, headers=cabeceras, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/agents';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => $cabeceras,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	peticion, _ := http.NewRequest("GET", os.Getenv("ASIXTO_BASE_URL")+"/v1/agents", nil)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

```json
{
  "data": [{ "id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13", "name": "Cobranza", "active": true }],
  "total": 1
}
```

Solo lista agentes que pueden atender un turno ahora mismo: uno desactivado no aparece, porque ofrecerle un
`agent_id` que la operación siguiente rechaza con `400` sería peor que no ofrecerlo. **Crear o editar agentes
no está en `/v1`**: eso vive en la consola, donde está su único escritor.

**Devuelve hasta 100 agentes.** `total` es el número real de activos, así que si su cuenta tuviera más de cien
lo vería ahí, y la respuesta traería además `omitted` con cuántos no cupieron. No hay cursor ni páginas: los
agentes se crean a mano en la consola y cien es holgado para cualquier cuenta. Si llega a ese punto, escríbanos
([página 10](./10-operation.md)) — preferimos hablarlo antes que publicar un formato de paginación que después
no podríamos cambiar. El recorte conserva el orden, así que el `agent_id` que guarde no se le mueve.

Ojo con las claves que ya tenía emitidas: `agents:read` se concede **al emitir**, así que una clave anterior a
este permiso responde `403 insufficient_scope` aquí. Emita una nueva y rótela ([página 9](./09-changelog.md)).

La lista se sirve **con hasta 60 segundos de retraso**: es la misma lectura que usa la consola y va cacheada por
cuenta. Un agente que active ahora puede tardar ese minuto en aparecer aquí, así que no encadene «crear en la
consola» con «listar por API» sin ese margen. Los `agent_id` no cambian, de modo que una vez obtenido puede
guardarlo: esta ruta es para descubrirlo, no para consultarla en cada turno.

## Los diez permisos de una clave

Una clave lleva los permisos que se le concedieron **al emitirla**, y son diez en total: uno por capacidad,
más los dos de lectura. Una clave sin ninguno no autoriza nada y toda petición responde `403 insufficient_scope`.

| Permiso | Qué autoriza | Qué **no** autoriza |
|---|---|---|
| `vision:analyze` | Analizar una imagen | Documentos, audio ni ninguna otra capacidad |
| `documents:analyze` | Analizar un documento que usted aloja por URL | Subir el archivo, ni analizar imágenes |
| `video:analyze` | Analizar un video que usted aloja por URL | Documentos ni imágenes: cada capacidad lleva el suyo |
| `audio:transcribe` | Convertir audio a texto | **Generar** audio: eso es `speech:synthesize` |
| `speech:synthesize` | Generar audio a partir de texto | Leer audio. Leer y generar son permisos distintos a propósito |
| `sentiment:analyze` | Analizar el tono de un texto | Nada del turno del agente: el turno no lo hace por su cuenta |
| `calls:initiate` | **Originar** una llamada de voz con su agente | Leer la llamada: eso es `jobs:read`. Colgarla no existe en la API |
| `jobs:read` | Consultar un trabajo y leer el stream de un turno | Crear nada. Es la única entrada que no gasta créditos ni cupo |
| `agent:converse` | Abrir un turno de conversación con su agente | Leer la respuesta de ese turno: leerla la autoriza `jobs:read` |
| `agents:read` | Listar sus agentes activos para obtener el `agent_id` | Crear, editar ni desactivar un agente: eso es de la consola |

**`calls:initiate` es «originar», no «llamadas».** Un permiso de recurso genérico habría cubierto también
leer o colgar, que son cosas que se pueden querer conceder sin conceder la de marcar: es la única operación
de la superficie que gasta dinero por minuto en un tercero y expone su número al destinatario.

**No hay verbo para colgar una llamada en curso, y no lo damos por supuesto.** La API no publica ninguna ruta
que corte una llamada ya originada: la llamada termina cuando la conversación termina, cuando se agota el plazo
del trabajo o cuando un operador de su empresa pulsa el botón de parada **desde la consola**. Si necesita
detener una llamada, ese es hoy el único camino. Tampoco hay estado `canceled`: los cinco valores de `status`
son los de la [página 17](./17-jobs.md) y ninguno significa «cancelado».

### `jobs:read` es el que le deja leer lo que pagó

Cubre **dos** entradas, y por eso es el que más falta se echa cuando no está: la consulta del trabajo
(`GET /v1/jobs/{job_id}`, [página 17](./17-jobs.md)) y la lectura del stream del turno, cuyo `stream_url` viene
en el acuse de `POST /v1/agent/turns`.

Esa segunda ruta admite **dos credenciales distintas** para la misma autorización: su clave de API con
`jobs:read`, y el token de sesión de nuestra consola con su propio permiso de lectura de conversación. El
criterio es el mismo en los dos casos —quien paga el turno puede leer su respuesta— y un trabajo de otra
cuenta responde igual que uno inexistente.

🔴 **Sin `jobs:read`, un turno se paga y no se puede leer, y por eso toda clave nace con él.** La emisión
añade **siempre** `jobs:read` y `agents:read` a lo que usted marque: son las dos lecturas que no gastan
créditos ni cupo, y sin ellas quien integra no puede leer lo que pagó ni descubrir el `agent_id` que las
demás rutas le exigen ([página 9](./09-changelog.md)). La otra cara —la que hay que tener presente al
emitir— es que esas dos **no se pueden dejar fuera**: si su política es conceder el mínimo indispensable,
cuente con ellas dentro y no espere una clave que las excluya. La expansión ocurre **solo al emitir**, así
que una clave emitida antes de que uno de esos dos permisos existiera no lo gana por sí sola: hay que emitir
otra y rotarla.

### `agent:converse` no se concede «por si acaso»

Es la capacidad más cara de la superficie y la única que no se puede deshacer: abrir un turno gasta créditos,
invoca las herramientas de **su** servidor —que pueden escribir en sus sistemas— y ya ocurrió. Concederlo a
una clave emitida para transcribir audio es regalar eso. Es lo contrario de `jobs:read`: ahí lo que faltaba
era una lectura inocua, aquí lo que sobra es una escritura irreversible.

Los permisos de una clave ya emitida **no se editan desde la consola**: si le falta uno, emita otra con lo que
necesita, despliegue y revoque la anterior. Cada entorno admite dos claves activas justo para eso.

## `POST /v1/vision/analysis`

Análisis de una imagen. `multipart/form-data` con un solo archivo.

| Campo | Dónde | Obligatorio | Límite |
|---|---|---|---|
| `file` | parte del multipart | sí | 10 MiB |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/vision/analysis" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Idempotency-Key: img-4471" \
  -F "file=@albaran.jpg"
```

**Node.js**

```js
import { openAsBlob } from 'node:fs';

const cuerpo = new FormData();
cuerpo.append('file', await openAsBlob('albaran.jpg'), 'albaran.jpg');

const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/vision/analysis`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Idempotency-Key': 'img-4471',
  },
  body: cuerpo,
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/vision/analysis"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "img-4471",
}

archivos = {
    "file": open("albaran.jpg", "rb"),
}

respuesta = requests.post(url, headers=cabeceras, files=archivos, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/vision/analysis';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Idempotency-Key: img-4471',
];

$cuerpo = [
    'file' => new CURLFile('albaran.jpg'),
];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => $cuerpo,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)

func main() {
	var cuerpo bytes.Buffer
	formulario := multipart.NewWriter(&cuerpo)
	archivo, _ := os.Open("albaran.jpg")
	parte, _ := formulario.CreateFormFile("file", "albaran.jpg")
	io.Copy(parte, archivo)
	formulario.Close()

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/vision/analysis", &cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", formulario.FormDataContentType())
	peticion.Header.Set("Idempotency-Key", "img-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `analysis` (la descripción), `summary` y `degraded`. Si el modelo no pudo leer la imagen, la
operación **no se cobra** y responde el error de proveedor no disponible en vez de un análisis vacío.

## `POST /v1/documents/analysis`

Análisis de un documento que **usted aloja**. Se envía la URL, no el archivo: su almacenamiento ya sabe
servirlo con una URL firmada, y pedirle que nos lo suba sería mover megabytes dos veces.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `document_url` | texto | sí | 1–2048 caracteres |
| `mime_type` | texto | sí | 1–255 caracteres |
| `filename` | texto | sí | 1–255 caracteres |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/documents/analysis" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: doc-4471" \
  -d '{"document_url":"https://archivos.suempresa.com/contrato.pdf","mime_type":"application/pdf","filename":"contrato.pdf"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/documents/analysis`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'doc-4471',
  },
  body: JSON.stringify({
    "document_url": "https://archivos.suempresa.com/contrato.pdf",
    "mime_type": "application/pdf",
    "filename": "contrato.pdf"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/documents/analysis"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "doc-4471",
}

cuerpo = {
  "document_url": "https://archivos.suempresa.com/contrato.pdf",
  "mime_type": "application/pdf",
  "filename": "contrato.pdf"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/documents/analysis';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: doc-4471',
];

$cuerpo = {
  'document_url': 'https://archivos.suempresa.com/contrato.pdf',
  'mime_type': 'application/pdf',
  'filename': 'contrato.pdf'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"document_url":"https://archivos.suempresa.com/contrato.pdf","mime_type":"application/pdf","filename":"contrato.pdf"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/documents/analysis", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "doc-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `extracted_text`, `summary` y `degraded`.

Si no podemos descargar su documento —URL firmada caducada, su almacenamiento limitando la tasa, una cadena
de redirecciones que no termina— recibe `422 document_unreachable`, que existe para atribuirle el fallo a
quien lo puede arreglar: la petición está bien escrita y el recurso al que apunta no se pudo procesar.

## `POST /v1/video/analysis`

Análisis de un video que **usted aloja**, igual que el documento: se manda la URL en JSON, no el archivo.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `video_url` | texto | sí | 1–2048 caracteres |
| `mime_type` | texto | sí | 1–255 caracteres, y ha de ser un tipo `video/…` |
| `filename` | texto | sí | 1–255 caracteres |
| `duration_seconds` | entero | no | 1 a 600 segundos |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/video/analysis" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: video-4471" \
  -d '{"video_url":"https://archivos.suempresa.com/entrega.mp4","mime_type":"video/mp4","filename":"entrega.mp4","duration_seconds":45}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/video/analysis`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'video-4471',
  },
  body: JSON.stringify({
    "video_url": "https://archivos.suempresa.com/entrega.mp4",
    "mime_type": "video/mp4",
    "filename": "entrega.mp4",
    "duration_seconds": 45
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/video/analysis"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "video-4471",
}

cuerpo = {
  "video_url": "https://archivos.suempresa.com/entrega.mp4",
  "mime_type": "video/mp4",
  "filename": "entrega.mp4",
  "duration_seconds": 45
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/video/analysis';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: video-4471',
];

$cuerpo = {
  'video_url': 'https://archivos.suempresa.com/entrega.mp4',
  'mime_type': 'video/mp4',
  'filename': 'entrega.mp4',
  'duration_seconds': 45
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"video_url":"https://archivos.suempresa.com/entrega.mp4","mime_type":"video/mp4","filename":"entrega.mp4","duration_seconds":45}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/video/analysis", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "video-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `analysis` (la descripción), `summary` y `frames_analyzed`. Un análisis que no se pudo hacer **no se
cobra**: responde el error en vez de un informe vacío.

`filename` es solo para el informe y el registro: no se usa para resolver nada. `mime_type` que no sea de
video se rechaza, porque esta operación analiza video y la imagen tiene la suya.

### Los dos techos de la entrada

**50 MiB y 600 segundos**, y pasado cualquiera de los dos se rechaza **nombrándolo**, nunca recortando en
silencio: un análisis de parte del video se cobraría igual que el completo.

| Techo | Cómo se comprueba | Qué recibe si se pasa |
|---|---|---|
| 50 MiB | Un sondeo del enlace que usted declara | `413 payload_too_large`, con los bytes que anuncia su origen |
| 600 segundos | La duración que usted declara en `duration_seconds` | `400 validation_error`, que nombra el campo |

`duration_seconds` es opcional porque **la declara usted**: no sostenemos los bytes del video, así que no
podemos medirla por nuestra cuenta. Cuando falta, el techo que acota lo que llega es el de tamaño. Y
declararla de menos no abarata nada, porque el precio no depende de ella.

🔴 **La tarifa es plana: 270 créditos por video** ([página 12](./12-pricing.md)). Un video de diez segundos y
uno de cinco minutos cuestan lo mismo. Por eso la entrada tiene techos y por eso se rechaza en vez de
recortar.

## `POST /v1/audio/transcriptions`

Audio a texto. `multipart/form-data`, y el idioma va en la query.

| Campo | Dónde | Obligatorio | Límite |
|---|---|---|---|
| `file` | parte del multipart | sí | 25 MiB |
| `language` | query | no | ISO 639-1, 2–5 caracteres |

Formatos admitidos: FLAC, MP3, MP4, MPEG, MPGA, M4A, OGG, WAV y WEBM. Otro tipo recibe
`415 unsupported_media_type` con la lista, en vez de fallar más adelante.

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/audio/transcriptions?language=es" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Idempotency-Key: nota-4471" \
  -F "file=@nota.ogg"
```

**Node.js**

```js
import { openAsBlob } from 'node:fs';

const cuerpo = new FormData();
cuerpo.append('file', await openAsBlob('nota.ogg'), 'nota.ogg');

const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/audio/transcriptions?language=es`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Idempotency-Key': 'nota-4471',
  },
  body: cuerpo,
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/audio/transcriptions?language=es"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "nota-4471",
}

archivos = {
    "file": open("nota.ogg", "rb"),
}

respuesta = requests.post(url, headers=cabeceras, files=archivos, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/audio/transcriptions?language=es';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Idempotency-Key: nota-4471',
];

$cuerpo = [
    'file' => new CURLFile('nota.ogg'),
];

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => $cuerpo,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"mime/multipart"
	"net/http"
	"os"
)

func main() {
	var cuerpo bytes.Buffer
	formulario := multipart.NewWriter(&cuerpo)
	archivo, _ := os.Open("nota.ogg")
	parte, _ := formulario.CreateFormFile("file", "nota.ogg")
	io.Copy(parte, archivo)
	formulario.Close()

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/audio/transcriptions?language=es", &cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", formulario.FormDataContentType())
	peticion.Header.Set("Idempotency-Key", "nota-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

**Mande `language` solo con evidencia real del idioma.** Forzar uno que no es el del audio no falla: el
modelo **traduce**. Medido contra el proveedor — una nota en inglés con `language=es` vuelve en español. Sin
el parámetro se autodetecta, que es el comportamiento correcto por omisión.

**Se cobran 10 segundos como mínimo**, porque es lo que factura el proveedor: una nota de tres segundos
—el caso normal en un chat— cuesta diez. Va en `usage.quantity`, así que se puede conciliar.

`result`: `text` y `filtered_hallucination`. Un texto vacío **con** ese indicador puesto significa que
descartamos lo único que devolvió el proveedor: eso no se cobra. Un texto vacío **sin** el indicador es un
audio sin habla, y eso sí es un resultado.

## `POST /v1/audio/speech`

Texto a voz. **Devuelve el audio como cuerpo binario**, siguiendo la convención de la industria, así que el
sobre viaja en cabeceras.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `text` | texto | sí | 1–5.000 caracteres |
| `voice_id` | texto | sí | 1–100 caracteres |
| `model_id` | texto | no | 1–100 caracteres |
| `language_code` | texto | no | 2–10 caracteres |
| `voice_settings` | objeto | no | se reenvía sin interpretarlo |
| `speed` | número | no | 0,25 a 4 |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/audio/speech" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: voz-4471" \
  -d '{"text":"Su pedido llega el martes","voice_id":"es-CO-natural","speed":1}' \
  --output respuesta.mp3 -D cabeceras.txt
```

**Node.js**

```js
import { writeFile } from 'node:fs/promises';

const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/audio/speech`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'voz-4471',
  },
  body: JSON.stringify({
    "text": "Su pedido llega el martes",
    "voice_id": "es-CO-natural",
    "speed": 1
  }),
});

// El cuerpo es el audio: el consumo viaja en las cabeceras `X-Asixto-*`.
console.log(respuesta.headers.get('X-Asixto-Usage-Credits'));
await writeFile('respuesta.mp3', Buffer.from(await respuesta.arrayBuffer()));
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/audio/speech"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "voz-4471",
}

cuerpo = {
  "text": "Su pedido llega el martes",
  "voice_id": "es-CO-natural",
  "speed": 1
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

# El cuerpo es el audio: el consumo viaja en las cabeceras `X-Asixto-*`.
print(respuesta.headers["X-Asixto-Usage-Credits"])
open("respuesta.mp3", "wb").write(respuesta.content)
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/audio/speech';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: voz-4471',
];

$cuerpo = {
  'text': 'Su pedido llega el martes',
  'voice_id': 'es-CO-natural',
  'speed': 1
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
    CURLOPT_HEADER => true,
]);

$respuesta = curl_exec($ch);
curl_close($ch);

// El cuerpo es el audio y las cabeceras traen el consumo (`X-Asixto-*`).
file_put_contents('respuesta.mp3', $respuesta);
```

**Go**

```go
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"text":"Su pedido llega el martes","voice_id":"es-CO-natural","speed":1}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/audio/speech", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "voz-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	// El cuerpo es el audio: el consumo viaja en las cabeceras `X-Asixto-*`.
	fmt.Println(respuesta.Header.Get("X-Asixto-Usage-Credits"))
	archivoSalida, _ := os.Create("respuesta.mp3")
	io.Copy(archivoSalida, respuesta.Body)
}
```

| Cabecera | Qué trae |
|---|---|
| `X-Asixto-Request-Id` | lo mismo que `request_id` |
| `X-Asixto-Job-Id` | lo mismo que `job_id` |
| `X-Asixto-Usage-Operation` | lo mismo que `usage.operation` |
| `X-Asixto-Usage-Unit` | lo mismo que `usage.unit` |
| `X-Asixto-Usage-Quantity` | lo mismo que `usage.quantity` |
| `X-Asixto-Usage-Credits` | lo mismo que `usage.credits` |
| `X-Asixto-Audio-Duration-Ms` | duración del audio |
| `X-Asixto-Chars-Input` | caracteres que usted envió |
| `X-Asixto-Chars-Normalized` | en qué se convirtió su texto |
| `X-Asixto-Chars-Billed` | caracteres cobrados |

Las tres últimas están para que entienda por qué se le cobran más caracteres de los que escribió: el
proveedor cobra el texto **normalizado**, y la normalización expande —«Dr.» se dice «Doctor», «999» se dice
«novecientos noventa y nueve»—.

🔴 **Es la única operación cuyo resultado no se puede reenviar.** El resultado es el audio, y guardarlo
significaría megabytes por petición y una copia durable de lo que usted dictó. Repetir su clave de
idempotencia recibe `409 result_not_replayable` y **el cobro no se duplica**: si necesita el audio otra vez,
pida la síntesis con una clave nueva.

## `POST /v1/text/sentiment`

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `text` | texto | sí | 1–10.000 caracteres |
| `customer_id` | texto | no | 1–128 caracteres |
| `channel` | texto | no | 1–64 caracteres |
| `region_id` | texto | no | 1–64 caracteres |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/text/sentiment" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sent-4471" \
  -d '{"text":"Llevo tres días esperando y nadie me responde.","customer_id":"cliente-12345"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/text/sentiment`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'sent-4471',
  },
  body: JSON.stringify({
    "text": "Llevo tres días esperando y nadie me responde.",
    "customer_id": "cliente-12345"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/text/sentiment"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "sent-4471",
}

cuerpo = {
  "text": "Llevo tres días esperando y nadie me responde.",
  "customer_id": "cliente-12345"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/text/sentiment';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: sent-4471',
];

$cuerpo = {
  'text': 'Llevo tres días esperando y nadie me responde.',
  'customer_id': 'cliente-12345'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"text":"Llevo tres días esperando y nadie me responde.","customer_id":"cliente-12345"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/text/sentiment", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "sent-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

`result`: `sentiment`, `score` y `source`. `customer_id` es su identificador y es opaco para nosotros: se
usa para la tendencia del sentimiento de ese cliente, nunca para resolver nada de nuestra base.

## `POST /v1/calls` · asíncrona

Origina una llamada de voz con su agente. Responde `202` con un acuse: la conversación todavía no ha
empezado.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `to` | texto | sí | E.164 con `+` y país |
| `agent_id` | texto | sí | identificador de un agente de su cuenta |
| `max_duration_seconds` | entero | no | 1 a 900; sin él, 300 |
| `topic` | texto | no | hasta 200 caracteres |
| `variables` | objeto | no | ver [contexto por petición](#el-contexto-por-peticion-variables-y-metadata) |
| `metadata` | objeto | no | ídem |

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/calls" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: llamada-4471" \
  -d '{"to":"+573001112233","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","max_duration_seconds":300,"topic":"cobro de la factura 4471"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/calls`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'llamada-4471',
  },
  body: JSON.stringify({
    "to": "+573001112233",
    "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
    "max_duration_seconds": 300,
    "topic": "cobro de la factura 4471"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/calls"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "llamada-4471",
}

cuerpo = {
  "to": "+573001112233",
  "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
  "max_duration_seconds": 300,
  "topic": "cobro de la factura 4471"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/calls';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: llamada-4471',
];

$cuerpo = {
  'to': '+573001112233',
  'agent_id': 'a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13',
  'max_duration_seconds': 300,
  'topic': 'cobro de la factura 4471'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"to":"+573001112233","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","max_duration_seconds":300,"topic":"cobro de la factura 4471"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/calls", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "llamada-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

Acuse: `call_id`, `status` (`queued`), `max_duration_seconds`, `authorized_minutes` y `from`.

**`max_duration_seconds` del acuse puede ser MENOR que el que pidió**: es la forma de saber, antes de que
suene el teléfono, que su saldo solo paga parte de la conversación. La duración fuera de rango **se
rechaza**, no se recorta en silencio, porque es lo que decide cuántos créditos se reservan.

`from` es el número que verá el destinatario. Con `ak_test_` es **nuestro** número compartido y `to` tiene que
ser uno de sus números de prueba verificados (si no, `409 test_destination_not_verified`). Con `ak_live_` es
**el suyo**, y sin él listo la llamada no sale: `409 phone_number_required`, `409 phone_number_in_review` o
`402 phone_number_suspended`. Qué hacer en cada caso, en la [página 14](./14-api-quickstart.md).

Si la conducta de pruebas transfiere a un número que ya no está verificado, la llamada sale **sin
transferencia** y el acuse lo dice en `warnings`:

```json
{ "…": "…", "warnings": { "ignored_variables": [], "transfer_disabled": { "reason": "test_destination_not_verified" } } }
```

Repetir la petición con la misma `Idempotency-Key` **no repite el acuse**. Mientras la llamada vive, su
clave responde `409 request_in_flight`, sin `job_id`, y su `Retry-After` es el tiempo máximo que le queda a
la llamada (la duración autorizada que resta más un margen de cierre), no unos segundos. Cuando cuelga,
devuelve el resultado guardado con el `job_id` original y **los mismos `warnings` del acuse**: `call_id`,
`status` (`completed`), `duration_seconds` y `billed_minutes`. Cómo recuperar el `job_id` si perdió el acuse, en la
[página 17](./17-jobs.md#si-lo-que-perdio-es-el-acuse-de-una-llamada).

## `POST /v1/agent/turns` · asíncrona

Un turno de conversación con su agente. Responde `202` con la ruta por la que se lee la respuesta mientras
se genera.

| Campo | Tipo | Obligatorio | Límite |
|---|---|---|---|
| `message` | texto | sí | 1–20.000 caracteres |
| `channel` | texto | sí | uno de los canales admitidos |
| `agent_id` | texto | sí | identificador de un agente de su cuenta |
| `conversation_external_id` | texto | no | 1–128 caracteres |
| `history` | lista | no | hasta 40 mensajes y 20.000 caracteres sumados |
| `customer` | objeto | no | ver abajo |
| `variables` | objeto | no | ver [contexto por petición](#el-contexto-por-peticion-variables-y-metadata) |
| `metadata` | objeto | no | ídem |

### Canales admitidos

Son estos nueve: `whatsapp`, `facebook_messenger`, `instagram`, `telegram`, `email`, `sms`, `voice`,
`chatbot_web` y `groups`. Van en minúsculas y **tal cual** —WhatsApp no vale—, y cualquier valor fuera de esa
lista recibe `400 validation_error` con ella.

De dónde viene el mensaje es una **declaración suya**, no un canal que nosotros conectemos: no mandamos nada
por él. Lo que hace es cambiar cómo se le habla al cliente final —un correo no se redacta como un mensaje de
chat—, así que declare el real.

Cada mensaje de `history` lleva `role` (`customer` o `agent`), `content` y un `timestamp` opcional en ISO
8601. **El rol es obligatorio**: sin él, lo que dijo el agente se leería como algo que dijo el cliente y el
modelo razonaría sobre una conversación que nunca ocurrió. El orden lo da la lista, no la fecha.

`customer` acepta `external_id` (1–128), `name` (1–200), `phone` (1–32) y `email` (1–320). Son datos suyos y
opacos para nosotros: se le pasan al agente para que sepa con quién habla.

🔴 **`conversation_external_id` y `history` son excluyentes.** Dos fuentes para la misma conversación es una
ambigüedad que nadie puede resolver bien, y la que perdiera se descartaría en silencio.

- **Con `conversation_external_id`**: nosotros mantenemos la sesión. Usted manda solo el mensaje nuevo y la
  nombra con **su** identificador, así que el primer turno ya puede continuar una conversación sin una ida y
  vuelta previa. La clave es única por cuenta, así que nadie más ve la suya. La ventana vive 24 h de
  inactividad ([página 13](./13-data.md)).
- **Con `history`**: usted lleva la conversación y la envía en cada turno.

**cURL**

```bash
curl -sS -X POST "$ASIXTO_BASE_URL/v1/agent/turns" \
  -H "Authorization: Bearer $ASIXTO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: turno-4471" \
  -d '{"message":"¿Cuál es el estado de mi pedido #12345?","channel":"whatsapp","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","conversation_external_id":"cliente-12345"}'
```

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/agent/turns`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASIXTO_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'turno-4471',
  },
  body: JSON.stringify({
    "message": "¿Cuál es el estado de mi pedido #12345?",
    "channel": "whatsapp",
    "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
    "conversation_external_id": "cliente-12345"
  }),
});

const sobre = await respuesta.json();
console.log(sobre.job_id, sobre.usage.credits, sobre.result);
```

**Python**

```python
import os
import requests

url = f"{os.environ['ASIXTO_BASE_URL']}/v1/agent/turns"
cabeceras = {
    "Authorization": f"Bearer {os.environ['ASIXTO_API_KEY']}",
    "Idempotency-Key": "turno-4471",
}

cuerpo = {
  "message": "¿Cuál es el estado de mi pedido #12345?",
  "channel": "whatsapp",
  "agent_id": "a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13",
  "conversation_external_id": "cliente-12345"
}

respuesta = requests.post(url, headers=cabeceras, json=cuerpo, timeout=120)
respuesta.raise_for_status()

sobre = respuesta.json()
print(sobre["job_id"], sobre["usage"]["credits"], sobre["result"])
```

**PHP**

```php
<?php

$url = getenv('ASIXTO_BASE_URL') . '/v1/agent/turns';
$cabeceras = [
    'Authorization: Bearer ' . getenv('ASIXTO_API_KEY'),
    'Content-Type: application/json',
    'Idempotency-Key: turno-4471',
];

$cuerpo = {
  'message': '¿Cuál es el estado de mi pedido #12345?',
  'channel': 'whatsapp',
  'agent_id': 'a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13',
  'conversation_external_id': 'cliente-12345'
};

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST => true,
    CURLOPT_HTTPHEADER => $cabeceras,
    CURLOPT_POSTFIELDS => json_encode($cuerpo),
]);

$respuesta = curl_exec($ch);
curl_close($ch);

$sobre = json_decode($respuesta, true);
echo $sobre['job_id'], ' ', $sobre['usage']['credits'], PHP_EOL;
```

**Go**

```go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
)

func main() {
	cuerpo := strings.NewReader(`{"message":"¿Cuál es el estado de mi pedido #12345?","channel":"whatsapp","agent_id":"a4f1c2e0-6d3b-4e7a-9c21-8f0e5b7d4a13","conversation_external_id":"cliente-12345"}`)

	peticion, _ := http.NewRequest("POST", os.Getenv("ASIXTO_BASE_URL")+"/v1/agent/turns", cuerpo)
	peticion.Header.Set("Authorization", "Bearer "+os.Getenv("ASIXTO_API_KEY"))
	peticion.Header.Set("Content-Type", "application/json")
	peticion.Header.Set("Idempotency-Key", "turno-4471")

	respuesta, err := http.DefaultClient.Do(peticion)
	if err != nil {
		panic(err)
	}
	defer respuesta.Body.Close()

	var sobre map[string]any
	json.NewDecoder(respuesta.Body).Decode(&sobre)
	fmt.Println(sobre["job_id"], sobre["usage"], sobre["result"])
}
```

Acuse: `stream_id`, `status` y `stream_url`. `stream_url` es la ruta por la que se lee la respuesta mientras
se genera, y su contrato —eventos, forma de cada trama y reanudación— está en la
[página 19](./19-api-stream.md).

El turno se cobra **por respuesta generada**, con un precio único ([página 12](./12-pricing.md)). Leer la
respuesta no cuesta nada, ni la primera vez ni al reanudar: el cobro sigue a la generación, no a la
entrega. Si su cliente se desconecta a mitad, el turno cuesta lo mismo.

## El contexto por petición: `variables` y `metadata`

Las dos surten a `POST /v1/calls` y a `POST /v1/agent/turns`, con la MISMA forma y los mismos techos, y
significan cosas opuestas:

| | `variables` | `metadata` |
|---|---|---|
| ¿La ve el agente? | **Sí**: rellena los huecos de su configuración y las dice | **No**, nunca |
| ¿Vuelve en el webhook? | No | **Sí**, en `data.metadata` |
| Para qué es | personalizar lo que se dice | conciliar con su propio sistema |

Los techos son los mismos para las dos: hasta **20 claves**, cada clave en minúsculas, dígitos y `_`
empezando por letra y hasta 40 caracteres (`loan_id`, `nombre_cliente`), cada valor de **hasta 512
caracteres** y el objeto entero **hasta 4 KB**. Los valores van en texto, número o booleano —se guardan como
texto— y **no admiten objetos ni listas dentro**: una clave anidada recibe `400 validation_error` que la
nombra, en vez de descartarse en silencio.

### `variables`: los huecos de la configuración de su agente

En la consola, el saludo y el mensaje de apertura de un agente pueden llevar huecos con la forma
`{{clave}}`. Lo que manda en `variables` es lo que los rellena, en esta petición y solo en esta:

```json
{ "to": "+573001112233", "agent_id": "…", "variables": { "nombre_cliente": "Ana", "monto": "1.250.000" } }
```

Con el saludo `Hola {{nombre_cliente}}, le llamo por su saldo de {{monto}}`, el agente dice
`Hola Ana, le llamo por su saldo de 1.250.000`.

Las dos reglas que le importan, y son opuestas a propósito:

- 🔴 **Falta una que el agente declara ⇒ `400 validation_error` que la nombra.** No se deja el hueco: un
  agente leyendo `{{monto}}` en voz alta a su cliente es peor que un rechazo que usted ve en su log.
- **Sobra una que ninguna plantilla usa ⇒ la petición SE ACEPTA y se lo decimos.** La respuesta trae
  `warnings.ignored_variables` con esas claves. Es así porque el texto del agente se edita en la consola y no
  puede tumbarle su integración cada vez que alguien reescribe una frase — pero tampoco puede quedarse
  callado, o usted daría por hecho que su variable actuó.

```json
{ "request_id": "req_…", "job_id": "job_…", "usage": { "…": "…" }, "result": { "…": "…" },
  "warnings": { "ignored_variables": ["descuento"] } }
```

**`warnings` no viene si no hay nada que avisar**: la clave está ausente, no vacía. Trate su presencia como
la señal, no su contenido. Cuando viene, `ignored_variables` está siempre (vacía si no sobró ninguna) y, en
`POST /v1/calls`, puede venir además `transfer_disabled` (ver la operación, arriba). Qué trae el replay
de la clave depende de la superficie:

- **`POST /v1/calls`**: los mismos `warnings` que el acuse original, porque se guardan con el trabajo al admitir
  la llamada.
- **Turno del agente**: se recalculan con el agente tal como está al repetir. Con las mismas `variables` y el
  mismo texto del agente salen iguales; si alguien editó sus plantillas en la consola entre los dos intentos,
  `ignored_variables` puede cambiar.

### `metadata`: sus identificadores, de ida y de vuelta

Es opaco para nosotros y para el agente: no entra en el prompt, el modelo no lo ve y nadie lo dice. Es el
sitio de su `loan_id`, su `case_id` o su número de expediente. Vuelve **tal cual** en el webhook del cierre,
dentro de `data.metadata` ([página 11](./11-webhooks.md)), y con eso cuadra el resultado contra su sistema
sin guardar nuestro `job_id` en ningún sitio.

Vuelve también cuando el trabajo **vence sin cierre** (`job.expired`), que es justo el caso en el que le hace
más falta.

### Idempotencia: una clave identifica una operación, no una plantilla

Si repite una `Idempotency-Key` con **otras `variables`**, la petición se rechaza con
`409 idempotency_key_reused` en vez de devolverle el resultado de la primera. Reintentar la misma petición
—mismas variables— sigue siendo idempotente y le devuelve el mismo trabajo. Cambiar solo el `metadata` **no**
cuenta como otra operación: es un dato suyo de conciliación, no parte de lo que pidió.

## Lo que el Gateway rellena y usted no puede escribir

En el turno, la identidad de su cuenta, la configuración del agente y la credencial con la que validamos
las herramientas las pone el Gateway. Un cuerpo que intente escribir cualquiera de esas casillas **se
rechaza** en vez de ignorarse: ignorarlo dejaría al integrador creyendo que configuró algo.

Elegir **entre** sus agentes es suyo. Configurarlos se hace en la consola, y es el producto.
