# 17 · Trabajos, resultados y reintentos

Toda petición a `/v1` crea un **trabajo**, y su identificador viaja en el sobre como `job_id`. En las cinco
operaciones síncronas eso es un detalle de contabilidad; en las dos asíncronas es el hilo por el que se sigue
la operación.

## `GET /v1/jobs/{job_id}`

Es la **red de seguridad de lectura**: quien no monte receptor de webhooks tiene que poder preguntar por su
trabajo y recibir una respuesta que distinga todos los casos.

**cURL**

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

**Node.js**

```js
const respuesta = await fetch(`${process.env.ASIXTO_BASE_URL}/v1/jobs/job_9f2a7c41`, {
  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/jobs/job_9f2a7c41"
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/jobs/job_9f2a7c41';
$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/jobs/job_9f2a7c41", 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
{
  "request_id": "req_9c67575147ae560b557cf5f3",
  "job_id": "cd5e91f3-371e-4ffc-b168-a0beb22f31a5",
  "status": "succeeded",
  "operation": "agent_turn",
  "environment": "live",
  "created_at": "2026-08-30T00:43:59.601Z",
  "started_at": "2026-08-30T00:43:59.640Z",
  "finished_at": "2026-08-30T00:44:01.354Z",
  "result_state": "available",
  "result": { "status": "completed", "stream_id": "cd5e91f3-…", "last_sequence": 1 },
  "usage": { "operation": "agent_turn", "unit": "request", "quantity": 1, "credits": 11 },
  "credits_charged": 11,
  "error": null
}
```

Necesita el permiso `jobs:read`. **Consultar no es ejecutar**, y por eso no reutiliza el permiso de la
operación que creó el trabajo: es la única entrada de la superficie que no gasta créditos ni cupo, así que
puede concedérsela a su panel de soporte sin concederle marcar llamadas ni sintetizar audio.

Los cuatro campos que comparte con el sobre —`request_id`, `job_id`, `usage` y `result`— **significan lo
mismo y se llaman igual**, para que escriba una sola capa de transporte y la use en el acuse, en la
repetición de una clave y en esta consulta.

`request_id` aquí es el de **esta consulta**, no el de la petición que creó el trabajo.

**Lo que no se publica, y es deliberado:** la entrada de su petición —que no se persiste— y la clave de API
que lo creó. El `environment` sí, porque un trabajo no cruza de entorno y una integración que promociona de
pruebas a producción necesita poder verlo.

## `status`: el ciclo de vida

| Valor | Qué significa |
|---|---|
| `queued` | Aceptado, sin empezar |
| `running` | En curso |
| `succeeded` | Terminó bien |
| `failed` | Terminó con un fallo. `error.code` dice cuál |
| `expired` | Se agotó su plazo sin cerrarse |

Los tres últimos son terminales. `expired` no es lo mismo que `failed`: un trabajo cuyo plazo vence libera
su reserva de créditos sin que nadie lo haya cerrado, y confundirlo con un fallo haría imposible auditar esa
liberación.

## `result_state`: cinco casos, no un `result` que a veces es nulo

Un nulo colapsaría situaciones cuya respuesta correcta es distinta.

| Valor | Qué significa | Qué hacer |
|---|---|---|
| `pending` | El trabajo no ha terminado | Vuelva a consultar |
| `available` | El resultado está en `result` | Úselo |
| `expired` | Venció su ventana de retención | Pida la operación con una clave nueva |
| `not_stored` | Esa operación no guarda su resultado por diseño | Pídala con una clave nueva |
| `unavailable` | Terminó sin resultado utilizable | Mire `status` y `error` |

La diferencia entre `expired` y `not_stored` importa: en el primer caso **hubo** un resultado y se purgó; en
el segundo nunca hubo copia. Hoy `not_stored` solo lo produce la síntesis de voz.

## Cuánto vive el resultado

El resultado tiene una **ventana de retención de 24 h**, contada desde que el trabajo se cierra. Dentro de la
ventana, repetir su clave de idempotencia le devuelve el **mismo** resultado sin volver a cobrar; pasada, el
resultado se purga y `result_state` pasa a `expired`.

**`credits_charged` sobrevive a esa purga**, y se publica aparte de `usage` justamente por eso: pasada la
ventana queda el importe y no el desglose. Con solo `usage`, un trabajo consultado al día siguiente parecería
no haber costado nada.

El resto de las ventanas del contrato están en la [página 13](./13-data.md).

## Repetir una clave de idempotencia: caso por caso

Es la misma decisión que toma la consulta de un trabajo, vista desde el otro lado.

| Estado del trabajo anterior | Qué recibe |
|---|---|
| Todavía en curso | `409 request_in_flight` |
| Terminó bien y su resultado se guarda | El **mismo** `job_id`, el mismo `usage` y el resultado. Sin cobrar otra vez |
| Terminó bien y su resultado no se guarda | `409 result_not_replayable`. El cobro no se duplica |
| Terminó bien y su resultado venció la ventana de retención de 24 h | `409 result_not_replayable`, y el mensaje nombra la ventana. Pídala con una clave nueva |
| Terminó con un fallo | El **mismo fallo** que la primera vez |
| Caducó | `409 job_not_completed` |
| La clave se usó para otra operación, o con otras `variables` | `409 idempotency_key_reused` |

Que un fallo se republique tal cual es lo que hace que reintentar con la misma clave sea seguro: no se vuelve
a ejecutar nada ni se vuelve a cobrar. Si lo que quiere es **volver a intentarlo de verdad**, use una clave
nueva.

## Cómo seguir una operación asíncrona

Dos formas, y no son excluyentes:

1. **Webhooks** ([página 11](./11-webhooks.md)): le avisamos cuando el trabajo llega a un estado terminal.
   Es lo que evita consultar en bucle.
2. **Consulta** de `GET /v1/jobs/{job_id}`: siempre disponible, y es lo que usa quien todavía no ha montado
   el receptor o quien está diagnosticando un caso concreto.

Si va a consultar en bucle, espacie las consultas: la consulta no gasta créditos pero sí cuenta para el
techo de tasa de la plataforma ([página 16](./16-api-errors.md)).

Para el turno del agente hay además un tercer camino, que es leer la respuesta **mientras se genera** por el
`stream_url` del acuse. Leerla no cuesta nada, ni la primera vez ni al reanudar
([página 12](./12-pricing.md)).

Ese `stream_url` lo autoriza **el mismo `jobs:read`** que esta consulta, y no el permiso con el que abrió el
turno: una clave que puede conversar pero no leer se queda sin la respuesta que ya pagó. Los detalles del
permiso están en la [página 15](./15-api-reference.md).

## Qué hacer cuando se pierde una respuesta

Es el caso para el que existe todo lo anterior: su proceso murió, su tiempo de espera se agotó, su red
falló.

1. **Repita la petición con la MISMA clave de idempotencia.** Si la operación ocurrió, recibe su resultado
   sin pagarlo dos veces; si no ocurrió, se ejecuta ahora.
2. Si recibe `409 request_in_flight`, la anterior sigue viva. En una operación síncrona, espere lo que
   indica `Retry-After` y repita con la misma clave, o consulte el trabajo si conserva su `job_id`. En una
   llamada, `Retry-After` es el tiempo máximo que le queda (puede durar hasta 900 s, `max_duration_seconds`,
   más un margen de cierre): no reintente antes, porque mientras habla solo consume su límite de peticiones.
   Espere al webhook del cierre o a que pase ese tiempo, y repita entonces
   ([Si lo que perdió es el acuse de una llamada](#si-lo-que-perdio-es-el-acuse-de-una-llamada)).
3. Si recibe `409 result_not_replayable` o `job_not_completed`, la operación no se puede recuperar: pídala
   con una clave nueva.

### Si lo que perdió es el acuse de una llamada

Una llamada aceptada sigue en curso hasta que cuelga, y su trabajo no es terminal mientras tanto. Por eso,
**mientras habla, la misma `Idempotency-Key` responde `409 request_in_flight`**: el `202` del acuse no se
repite. Ese `409` lleva en `Retry-After` los segundos que le quedan como máximo a la llamada, que es una
cota: si cuelga antes, la misma clave ya devuelve su resultado; mientras siga hablando, devuelve el mismo
`409`. Cuando cuelga, la misma clave devuelve el resultado guardado de la llamada
(`status` `completed`, `duration_seconds`, `billed_minutes`) con el `job_id` original y los mismos `warnings`
del acuse; si la llamada falló, devuelve el mismo fallo.

Si perdió el `202`, perdió también el `job_id`, y **mientras la llamada vive la API no se lo devuelve**: el
`409` no lo trae y no hay un listado de trabajos en `/v1`, solo `GET /v1/jobs/{job_id}`. Lo recupera de dos
formas, las dos al colgar:

- **El webhook del cierre** (`job.succeeded`, `job.failed` o `job.expired`) trae `job_id` y, en
  `data.metadata`, el `metadata` que envió al originarla. Envíe en él su identificador y podrá casar el cierre
  aunque no guardara nada nuestro.
- **Repetir la petición con la misma clave** cuando la llamada ya terminó.

En la consola, el listado de trabajos filtra por entorno, estado, operación y fechas, pero no por clave de
idempotencia.

Lo que no conviene: reintentar **sin** clave de idempotencia. Es la única forma de acabar pagando dos veces
por lo mismo.
