# Async jobs

> The job object, status lifecycle, polling with backoff, listing and filtering, cancellation, 24-hour output retention, and idempotent submits.

Source: https://infro.io/docs/api/jobs

> **Note:** INFRO is in private early access. Running these examples requires an early-access account, an active trial or subscription entitlement, and your own provider connection. Self-serve purchasing is not generally open. See [Early access](https://infro.io/early-access).

Work that takes too long for a single HTTP request returns a job instead of a result. Today only [video generation](https://infro.io/docs/modalities/video) creates jobs; images and audio are synchronous and return inline. Any future async endpoint uses the same job API.

A job is created with `202 Accepted`, moves through `queued` and `running`, and settles in exactly one terminal state. Terminal states do not change, and output files are kept for 24 hours. A key reads only jobs inside its own scope; an unknown job ID, or one outside that scope, answers `400 invalid_request_error`.

```text
GET  https://api.infro.io/v1/jobs/{id}
GET  https://api.infro.io/v1/jobs
POST https://api.infro.io/v1/jobs/{id}/cancel
```

## The job object

```json
{
  "id": "job_7d41ba90",
  "object": "job",
  "type": "video.generation",
  "status": "succeeded",
  "model": "kuaishou/kling-o3",
  "route": "primary",
  "provider": "openai-compatible",
  "connection": "conn_8b21c4e0",
  "progress": 1,
  "created_at": 1755950000,
  "started_at": 1755950006,
  "completed_at": 1755950139,
  "expires_at": 1756036539,
  "output": {
    "url": "https://api.infro.io/media/vid/2026-09-24/org_.../job_7d41ba90.mp4?exp=1756036539&sig=...",
    "duration_seconds": 5,
    "resolution": "720p",
    "aspect_ratio": "16:9",
    "mime_type": "video/mp4"
  },
  "usage": {
    "seconds": 5,
    "cost": 1.25,
    "price_source": "list"
  },
  "error": null
}
```

- `id` (string) — Opaque identifier prefixed `job_`. Stable for the life of the job, including when the submit fails over to another target on your route.
- `object` (string) — Always `job`.
- `type` (string) — What produced the job, e.g. `video.generation`. Treat it as an open set — new async endpoints add new types.
- `status` (string) — `queued`, `running`, `succeeded`, `failed`, or `canceled`. The last three are terminal.
- `model` (string) — The model that served the job. When you requested a route, this is the target model that ran.
- `route` (string | null) — Which position in the model's chain ran the work — `primary`, or `standby_a` and so on. Null while `queued`, because nothing has been dispatched yet.
- `provider / connection` (string | null) — The provider kind and the ID of your connection that ran the render. Null while `queued`.
- `progress` (number) — Best-effort `0`–`1` completion estimate, updated each time INFRO checks the provider (every one to five minutes) and `1` on success. Providers that report nothing leave it at `0`, so never build UI that requires it to move.
- `created_at / started_at / completed_at` (integer | null) — Unix seconds. `started_at` is set when a provider accepts the submit, `completed_at` when the job becomes terminal (including a cancel); both are null until then.
- `expires_at` (integer | null) — Unix seconds at which the output link stops working — 24 hours after completion. Null until the job succeeds, and null again once the output has been deleted.
- `output` (object | null) — Result payload, present only on `succeeded`. Shape depends on `type`; for `video.generation` it is `{url, duration_seconds, resolution, aspect_ratio, mime_type}`.
- `usage` (object | null) — Metered units plus `cost`, an estimate of what your provider will bill. `{seconds, cost, price_source}` for video; `cost` is `null` when the model is unpriced. Present only on `succeeded` — INFRO records no cost for failed and canceled jobs.
- `error` (object | null) — `{message, type, code}` on `failed`, using the same shape and codes as synchronous [errors](https://infro.io/docs/api/errors). Null otherwise.

## Status lifecycle

1. `queued` — the request was accepted and validated. The render is being submitted to one of your connections, usually within seconds.
2. `running` — a provider accepted the render; `route`, `provider`, `connection`, and `started_at` are set. Failover happens only while submitting: a running render is never moved to another connection.
3. `succeeded` — terminal. `output`, `usage`, and `completed_at` are populated, and `expires_at` starts the 24-hour clock on the output file.
4. `failed` — terminal. No target on the route accepted the submit, the provider reported the render failed, or it was still unfinished after 60 status checks (`408 request_timeout`); `error` says why. INFRO records no cost.
5. `canceled` — terminal, and only reachable from `queued` or `running` via the cancel endpoint. INFRO stops tracking the render and records no cost; it does not currently cancel the render at your provider.

## Retrieving a job

**curl**

```bash
curl https://api.infro.io/v1/jobs/job_7d41ba90 \
  -H "Authorization: Bearer $INFRO_API_KEY"
```

**Python**

```python
import os

import requests

API_KEY = os.environ["INFRO_API_KEY"]

res = requests.get(
    "https://api.infro.io/v1/jobs/job_7d41ba90",
    headers={"Authorization": f"Bearer {API_KEY}"},
    timeout=30,
)
res.raise_for_status()

job = res.json()
print(job["status"], job["progress"])
```

**TypeScript**

```typescript
const res = await fetch("https://api.infro.io/v1/jobs/job_7d41ba90", {
  headers: { Authorization: `Bearer ${process.env.INFRO_API_KEY}` },
});

if (!res.ok) throw new Error(await res.text());

const job = await res.json();
console.log(job.status, job.progress);
```

**Dart**

```dart
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;

Future<Map<String, dynamic>> fetchJob(String jobId) async {
  final apiKey = Platform.environment['INFRO_API_KEY']!;

  final res = await http.get(
    Uri.parse('https://api.infro.io/v1/jobs/$jobId'),
    headers: {'Authorization': 'Bearer $apiKey'},
  );

  if (res.statusCode != 200) {
    throw Exception('infro: ${res.statusCode} ${res.body}');
  }

  return jsonDecode(res.body) as Map<String, dynamic>;
}

Future<void> main() async {
  final job = await fetchJob('job_7d41ba90');
  print(job['status']);
}
```

## Polling with backoff

Polling is the fallback for clients that cannot receive a callback. Four rules keep it cheap and well-behaved:

- Start around 2 seconds, multiply the delay by 1.5 after each check, and cap it at 30 seconds. Jobs rarely finish faster than the first interval, so a tighter loop buys nothing.
- Add a little jitter when many workers poll at once, so retries do not synchronize into bursts.
- Set your own deadline and give up on it. A job with no terminal state after your timeout is a job to surface to the user, not to poll forever.
- Poll from a worker or a background isolate, never from inside a request handler that a user is waiting on.

A complete submit-then-poll implementation is in [Video generation](https://infro.io/docs/modalities/video). Poll responses are cheap but not free — they count against your [rate limits](https://infro.io/docs/api/limits).

> **Note:** If your code runs anywhere that can accept an inbound HTTPS request, use [webhooks](https://infro.io/docs/api/webhooks) instead. One signed delivery replaces the entire polling loop.

## Listing and filtering

```bash
curl "https://api.infro.io/v1/jobs?status=running&limit=20" \
  -H "Authorization: Bearer $INFRO_API_KEY"
```

- `status` (string) — Filter to one status. Repeat the parameter to match several, e.g. `?status=queued&status=running` for everything still in flight.
- `type` (string) — Filter by job type, e.g. `video.generation`.
- `limit` (integer) — Page size, `1`–`100`. Defaults to `20`.
- `after` (string) — Cursor for the next page: pass the `next_cursor` from the previous response.

```json
{
  "object": "list",
  "data": [
    {
      "id": "job_9a02f7ae",
      "object": "job",
      "type": "video.generation",
      "status": "running",
      "model": "runway/gen-4",
      "route": "primary",
      "provider": "openai-compatible",
      "connection": "conn_8b21c4e0",
      "progress": 0.4,
      "created_at": 1755950320
    }
  ],
  "has_more": true,
  "next_cursor": "job_9a02f7ae"
}
```

Jobs come back newest first, each as the full job object. Walk pages by passing `next_cursor` as `after` until `has_more` is false. An unknown `status` value is refused with `400 invalid_request_error`. Listing is the quickest way to find work orphaned by a crashed worker: filter on `queued` and `running` at startup and re-attach.

## Cancellation

```bash
curl -X POST https://api.infro.io/v1/jobs/job_7d41ba90/cancel \
  -H "Authorization: Bearer $INFRO_API_KEY"
```

Cancel returns the updated job. It is valid only from `queued` or `running`; calling it on a terminal job returns `400 invalid_request_error`. Cancellation races with completion — if the render finished a moment before your call, you get the `succeeded` job back with its output and cost estimate. Cancel does not currently stop the render at your provider, and an unknown job ID also answers `400 invalid_request_error`. Branch on the `status` in the response rather than assuming the cancel took effect.

## Retention

Output files live for 24 hours after completion, at `expires_at`. After that the URL returns 404 and INFRO deletes its copy of the file; the job record itself stays readable with `output` and `expires_at` set to null, so you keep the record of what ran, on which provider, at what estimated cost.

Add `download=1` to any output link — a job's `output.url`, an image's `data[].url`, or a stored speech file's `audio.url` — to receive the same file as an attachment (`Content-Disposition: attachment`), named after the request or job ID, so a browser saves it instead of displaying it. The signature still applies and the parameter does not extend the expiry.

> **Warning:** Treat the output URL as a handoff, not as storage. Download the file — or copy it straight to your own bucket — as soon as the job succeeds. Regenerating an expired render is billed again by your provider and will not be identical unless you pinned a `seed`.

## Idempotency

Submitting async work twice is expensive, and a retried POST after a timeout is exactly the situation where it happens. Send an `Idempotency-Key` header on `POST /v1/videos`: any unique string up to 255 characters, a UUID being the obvious choice.

```bash
curl https://api.infro.io/v1/videos \
  -H "Authorization: Bearer $INFRO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1b9d0e-2c19-4a3a-9d2a-8fd6b3b2f7c1" \
  -d '{
    "model": "kuaishou/kling-o3",
    "prompt": "Slow dolly over a foggy pine ridge at dawn",
    "duration_seconds": 5
  }'
```

- INFRO stores the first `202` response for a key and replays it for 24 hours, so a retry returns that original response — the job as it was when created, still `queued` — instead of starting a second render. Read the job's current state with `GET /v1/jobs/{id}`.
- Reusing a key with a different request body returns `400 invalid_request_error` with code `idempotency_key_reuse` — the key identifies one specific submission, not a slot. Retrying while the first request is still being processed returns `400` with code `idempotency_key_in_progress`; retry shortly.
- Keys are scoped to your organization, so two services can safely derive keys from their own record IDs as long as those IDs do not collide.
- Only `POST /v1/videos` honors the header. Every other endpoint ignores it; `GET` and cancel are already safe to retry.
