DocsAPI reference
Async jobs
The job object, status lifecycle, polling with backoff, listing and filtering, cancellation, 24-hour output retention, and idempotent submits.
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.
Work that takes too long for a single HTTP request returns a job instead of a result. Today only video generation 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.
GET https://api.infro.io/v1/jobs/{id}
GET https://api.infro.io/v1/jobs
POST https://api.infro.io/v1/jobs/{id}/cancelThe job object
{
"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
}idstring- Opaque identifier prefixed
job_. Stable for the life of the job, including when the submit fails over to another target on your route. objectstring- Always
job. typestring- What produced the job, e.g.
video.generation. Treat it as an open set — new async endpoints add new types. statusstringqueued,running,succeeded,failed, orcanceled. The last three are terminal.modelstring- The model that served the job. When you requested a route, this is the target model that ran.
routestring | null- Which position in the model's chain ran the work —
primary, orstandby_aand so on. Null whilequeued, because nothing has been dispatched yet. provider / connectionstring | null- The provider kind and the ID of your connection that ran the render. Null while
queued. progressnumber- Best-effort
0–1completion estimate, updated each time INFRO checks the provider (every one to five minutes) and1on success. Providers that report nothing leave it at0, so never build UI that requires it to move. created_at / started_at / completed_atinteger | null- Unix seconds.
started_atis set when a provider accepts the submit,completed_atwhen the job becomes terminal (including a cancel); both are null until then. expires_atinteger | 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.
outputobject | null- Result payload, present only on
succeeded. Shape depends ontype; forvideo.generationit is{url, duration_seconds, resolution, aspect_ratio, mime_type}. usageobject | null- Metered units plus
cost, an estimate of what your provider will bill.{seconds, cost, price_source}for video;costisnullwhen the model is unpriced. Present only onsucceeded— INFRO records no cost for failed and canceled jobs. errorobject | null{message, type, code}onfailed, using the same shape and codes as synchronous errors. Null otherwise.
Status lifecycle
queued— the request was accepted and validated. The render is being submitted to one of your connections, usually within seconds.running— a provider accepted the render;route,provider,connection, andstarted_atare set. Failover happens only while submitting: a running render is never moved to another connection.succeeded— terminal.output,usage, andcompleted_atare populated, andexpires_atstarts the 24-hour clock on the output file.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);errorsays why. INFRO records no cost.canceled— terminal, and only reachable fromqueuedorrunningvia 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 https://api.infro.io/v1/jobs/job_7d41ba90 \
-H "Authorization: Bearer $INFRO_API_KEY"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"])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);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. Poll responses are cheap but not free — they count against your rate limits.
If your code runs anywhere that can accept an inbound HTTPS request, use webhooks instead. One signed delivery replaces the entire polling loop.
Listing and filtering
curl "https://api.infro.io/v1/jobs?status=running&limit=20" \
-H "Authorization: Bearer $INFRO_API_KEY"statusstring- Filter to one status. Repeat the parameter to match several, e.g.
?status=queued&status=runningfor everything still in flight. typestring- Filter by job type, e.g.
video.generation. limitinteger- Page size,
1–100. Defaults to20. afterstring- Cursor for the next page: pass the
next_cursorfrom the previous response.
{
"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
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.
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.
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
202response for a key and replays it for 24 hours, so a retry returns that original response — the job as it was when created, stillqueued— instead of starting a second render. Read the job's current state withGET /v1/jobs/{id}. - Reusing a key with a different request body returns
400 invalid_request_errorwith codeidempotency_key_reuse— the key identifies one specific submission, not a slot. Retrying while the first request is still being processed returns400with codeidempotency_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/videoshonors the header. Every other endpoint ignores it;GETand cancel are already safe to retry.