Model APIs
Asynchronous: submit and poll
On this page
Submit a generation, store its job id, and let your application carry on while the model runs. Submit and poll is the recommended production pattern: a worker can resume following the same durable job after your original request or process ends.

How jobs work #
| Status | What is happening | SDK value |
|---|---|---|
queued |
Accepted and waiting to run; a provider outage or capacity wait can keep it here. | queued |
running |
The generation or delivery of its result is in progress. | running |
succeeded |
The finished asset is available. | succeeded |
failed |
Work ended without a delivered asset; inspect failure and its recorded refund outcome. |
failed |
canceled |
Its owner canceled it with POST /jobs/{id}/cancel. It is never delivered; cancellation says what the provider did and what happened to the credits. |
canceled |
status_detail |
Meaning | What to show |
|---|---|---|
provider_down |
The provider is unavailable; the queued job waits for recovery. | status_message |
upstream_at_capacity |
The provider is healthy but has no free generation slot; the job waits for capacity. | status_message |
Both details are non-terminal. Keep the existing job and its credit hold; do not submit replacements. Treat an unfamiliar detail as a plain queued job.
Key guarantees #
- A job is durable once you have its id. Follow it from another request or process.
- Credits are held at submit and settled or refunded at the end; Pricing and credits explains the ledger and the provider-billed content-policy exception.
- Duplicate submission protection prevents the same request and key from being billed twice inside its five-minute window.
- A job that cannot run ends with an explicit failure and refund; it is never silently dropped.
Submit a request #
Use this when you want the job id immediately and will follow it later. Image, video, audio and 3D generation return 202 Accepted with a Job; set generation and prompt enhancement have their own response shapes.
| Method | Path | What it does |
|---|---|---|
| POST | /generate/image |
Submit an image generation job (asynchronous). |
| POST | /generate/audio |
Submit an audio generation job (asynchronous). |
| POST | /generate/video |
Submit a video generation job (asynchronous). |
| POST | /generate/video/enhance-prompt |
Rewrite a video prompt for the model it will run on (signed in; 1 credit). |
| POST | /generate/set |
Generate a coordinated set of image Outputs. |
| POST | /generate/3d |
Turn product or object photos into a 3D model. |
| POST | /remove-background/video |
Remove a video's background (asynchronous). |
| POST | /restore/video |
Submit a video restoration/upscale job (asynchronous). |
curl --fail-with-body -sS https://api.nolgia.ai/v1/generate/image \
-H "Authorization: Bearer $NOLGIA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"flux-pro","prompt":"a paper-cut mountain range at dawn"}'
nolgia gen image --model flux-pro --prompt "a paper-cut mountain range at dawn" --no-wait
import { createNolgiaClient } from "@nolgia/sdk";
const nolgia = createNolgiaClient(process.env.NOLGIA_TOKEN!);
const { data: image, error } = await nolgia.POST("/generate/image", {
body: { model: "flux-pro", prompt: "a paper-cut mountain range at dawn", num_images: 1 },
});
if (error) throw new Error(`${error.title}: ${error.detail ?? ""}`);
console.log(image.id);
import os
from nolgia import AuthenticatedClient
from nolgia.api.generate import generate_image
from nolgia.models import GenerateImageRequest, ImageModel, Job
client = AuthenticatedClient(base_url="https://api.nolgia.ai/v1", token=os.environ["NOLGIA_TOKEN"])
job = generate_image.sync(client=client, body=GenerateImageRequest(model=ImageModel("flux-pro"), prompt="a paper-cut mountain range at dawn"))
if not isinstance(job, Job):
raise SystemExit(f"refused: {job}")
print(job.id)
use nolgia_client::{types, ClientBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ClientBuilder::new("https://api.nolgia.ai/v1")
.bearer_token(std::env::var("NOLGIA_TOKEN")?)
.build()?;
let prompt: types::GenerateImageRequestPrompt = "a paper-cut mountain range at dawn".parse()?;
let job = client.generate_image()
.body_map(|b| b.model("flux-pro").prompt(Some(prompt)).num_images(1u64))
.send().await?.into_inner();
println!("{}", job.id);
Ok(())
}
The production capture below is the actual 202 response for a flux-pro image request; its ids and timestamps are intact. The captured prompt differs from the reusable Quick Start prompt above.
{
"created_at": "2026-09-21T03:33:14.622149Z",
"id": "4a9b2242-f732-4a83-ba41-17448f5aa15f",
"modality": "image",
"model": "flux-pro",
"status": "queued",
"updated_at": "2026-09-21T03:33:14.622149Z",
"user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | |
modality |
Modality |
Yes | |
model |
string | Yes | |
status |
JobStatus |
Yes | |
status_detail |
string, nullable | No | Machine-readable refinement of a non-terminal status.… |
status_message |
string, nullable | No | Human-readable companion to status_detail, safe to render to the customer as-is.… |
progress |
number, nullable | No | |
created_at |
string | Yes | |
updated_at |
string | Yes | |
completed_at |
string, nullable | No |
Parameters #
Every submission selects a model and the fields supported by that model. The Common model arguments reference covers the request schemas and catalog capabilities.
| Field | Type | Required | Description |
|---|---|---|---|
confirmation_token |
string | No | Optional proof that this exact request and price were shown to the customer, from POST /jobs/cost.… |
model |
ImageModel |
Yes | |
prompt |
string | No | What to generate.… |
num_images |
integer | No | How many images to render from this one request. Each one is billed, so four images cost four times one. The ceiling is per-model (image.num_images_max); most models allow four. |
Price it first #
Call POST /jobs/cost with kind and the matching request body, such as image. The quote uses the same price calculation as submission. It submits nothing, reserves nothing, and charges nothing.
| Field | Type | Required | Description |
|---|---|---|---|
kind |
JobCostKind |
Yes | |
image |
GenerateImageRequest |
No | |
video |
GenerateVideoRequest |
No | |
audio |
GenerateAudioRequest |
No | |
three_d |
Generate3DRequest |
No | |
set |
GenerateSetRequest |
No |
| Field | Type | Required | Description |
|---|---|---|---|
kind |
JobCostKind |
Yes | |
model |
string | Yes | The model id the quote is for, after defaulting — not necessarily the one you sent. |
credits |
integer | Yes | Display credits. This is the number the submit will hold, computed by the same pricer the submit path uses, for exactly these settings. It is what to show the customer. |
basis |
one of one_generation, set_run |
Yes | Whether credits covers one generation or a whole set run. |
members |
integer, nullable | No | Number of set members credits covers. Present only when basis is set_run. |
duration_seconds |
integer, nullable | No | The billed duration the price is for, on a video quote. |
quality |
string, nullable | No | The resolved quality tier the price is for, when the model has one. |
settings |
array of JobCostSetting |
Yes | The settings that made this price, in the order a dialog should show them. Human-readable; do not parse it. |
balance_credits |
integer, nullable | No | The wallet balance this quote was compared against, when one could be read. |
sufficient_credits |
boolean | Yes | Whether the balance covers credits right now. Advisory — the submit re-checks. |
confirmation_token |
string | Yes | Short-lived, single-request proof that this price was quoted. Send it back as confirmation_token on the matching generate request. Opaque: do not parse or construct it. |
expires_at |
string | Yes | After this the token is refused and a submit carrying it fails with confirmation_rejected. Re-quote. |
The confirmation_token is optional. If you include one, an expired, malformed, foreign, or mismatched token is refused with 422 and confirmation_rejected. Quote again if you change the request or pass expires_at.
With NOLGIA_TOKEN set, quote the request first, then submit the same body with the confirmation_token the quote returned.
curl -sS https://api.nolgia.ai/v1/jobs/cost \
-H "Authorization: Bearer $NOLGIA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind":"image","image":{"model":"gpt-image-2","prompt":"a paper-cut mountain range at dawn"}}'
curl -sS https://api.nolgia.ai/v1/generate/image \
-H "Authorization: Bearer $NOLGIA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"model":"gpt-image-2","prompt":"a paper-cut mountain range at dawn","confirmation_token":"<confirmation_token from the quote>"}'
The matching production quote below held no credits. Its opaque confirmation token is redacted.
{
"balance_credits": 2573,
"basis": "one_generation",
"confirmation_token": "…",
"credits": 4,
"expires_at": "2026-09-21T03:43:14.171728407Z",
"kind": "image",
"model": "flux-pro",
"settings": [
{
"label": "Model",
"value": "flux-pro"
}
],
"sufficient_credits": true
}
Check status #
Use GET /jobs/{id} when a worker or application wants a snapshot without keeping a connection open. Read status, keep polling while it is queued or running, then handle the asset or typed failure.
curl --fail-with-body -sS "https://api.nolgia.ai/v1/jobs/$JOB_ID" \
-H "Authorization: Bearer $NOLGIA_TOKEN"
nolgia status "$JOB_ID"
import { createNolgiaClient } from "@nolgia/sdk";
const nolgia = createNolgiaClient(process.env.NOLGIA_TOKEN!);
const { data, error } = await nolgia.GET("/jobs/{id}", {
params: { path: { id: process.env.JOB_ID! } },
});
if (error) throw new Error(`${error.title}: ${error.detail ?? ""}`);
console.log(data);
import os
from nolgia import AuthenticatedClient
from nolgia.api.jobs import get_job
from nolgia.models import Job
client = AuthenticatedClient(base_url="https://api.nolgia.ai/v1", token=os.environ["NOLGIA_TOKEN"])
job = get_job.sync(os.environ["JOB_ID"], client=client)
if not isinstance(job, Job):
raise SystemExit(f"refused: {job}")
print(job.to_dict())
use nolgia_client::ClientBuilder;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ClientBuilder::new("https://api.nolgia.ai/v1")
.bearer_token(std::env::var("NOLGIA_TOKEN")?)
.build()?;
let id = std::env::var("JOB_ID")?.parse::<uuid::Uuid>()?;
let job = client.get_job().id(id).send().await?.into_inner();
println!("{job:?}");
Ok(())
}
Queued #
{
"created_at": "2026-09-21T03:33:14.622149Z",
"id": "4a9b2242-f732-4a83-ba41-17448f5aa15f",
"modality": "image",
"model": "flux-pro",
"status": "queued",
"updated_at": "2026-09-21T03:33:14.642113Z",
"user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
}
Running #
These are the fields that change, taken from the production stream's running frame; this is an excerpt, not a complete Job.
{"status":"running","progress":0}
Succeeded #
{
"asset": {
"created_at": "2026-09-21T03:33:21.706691Z",
"display_name": "Lighthouse on a cliff",
"expires_at": "2026-09-21T05:00:00Z",
"favorite": false,
"has_audio": false,
"id": "f7bc037c-d7d7-434a-8843-26675574de0d",
"mime_type": "image/png",
"modality": "image",
"model": "flux-pro",
"prompt": "a lighthouse on a cliff, paper-cut style",
"signed_url": "https://storage.googleapis.com/nolgia-generations-prod/dad27b53-a85b-4…",
"size_bytes": 418584,
"status": "ready",
"tags": [
],
"thumbnail_url": "https://storage.googleapis.com/nolgia-generations-prod/dad27b53-a85b-4…",
"user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
},
"completed_at": "2026-09-21T03:33:21.805937Z",
"created_at": "2026-09-21T03:33:14.622149Z",
"id": "4a9b2242-f732-4a83-ba41-17448f5aa15f",
"modality": "image",
"model": "flux-pro",
"status": "succeeded",
"updated_at": "2026-09-21T03:33:21.805937Z",
"user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
}
Failed #
A Gemini video failure was observed on 2026-09-20 with the message beginning “Video generation failed due to an internal server issue…”. There is no saved failed-job response fixture: this excerpt is built from JobFailure, using that observed message and refund, rather than presented as a captured full response.
{
"status": "failed",
"failure": {
"kind": "error",
"code": "job_failed",
"message": "Video generation failed due to an internal server issue…",
"credits_refunded": true
}
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | |
modality |
Modality |
Yes | |
model |
string | Yes | |
status |
JobStatus |
Yes | |
status_detail |
string, nullable | No | Machine-readable refinement of a non-terminal status.… |
status_message |
string, nullable | No | Human-readable companion to status_detail, safe to render to the customer as-is.… |
asset |
Asset |
No | |
error |
Error |
No | |
failure |
JobFailure |
No | |
progress |
number, nullable | No | |
created_at |
string | Yes | |
completed_at |
string, nullable | No |
| Field | Type | Required | Description |
|---|---|---|---|
code |
GenerationErrorCode |
No | Stable refinement of kind, present on every job that failed after this field shipped and derived from the recorded reason for older ones.… |
kind |
string | Yes | What stopped the job.… |
message |
string | Yes | Human-readable reason, safe to show the customer as-is. The same text as error.detail. |
credits_refunded |
boolean, nullable | No | What the credit ledger actually did with this job's credit hold, recorded when the hold settled.… |
Parameters #
| Parameter | In | Required | Description |
|---|---|---|---|
id |
path | Yes | Job UUID. |
Long-poll with wait #
Use long-poll when you want one HTTP request to return the finished job. GET /jobs/{id}/wait holds the connection until the job reaches a terminal state or the requested window closes; the SDK examples reuse the Quick Start's finish() loop. Set JOB_ID to the accepted job id.
curl --fail-with-body -sS "https://api.nolgia.ai/v1/jobs/$JOB_ID/wait?timeout_seconds=120" \
-H "Authorization: Bearer $NOLGIA_TOKEN"
nolgia wait "$JOB_ID"
import { createNolgiaClient } from "@nolgia/sdk";
const nolgia = createNolgiaClient(process.env.NOLGIA_TOKEN!);
async function finish(id: string, timeout: number) {
for (;;) {
const { data, response } = await nolgia.GET("/jobs/{id}/wait", {
params: { path: { id }, query: { timeout_seconds: timeout } },
});
if (response.status === 408) continue; // the wait window closed; the job is still running
if (data?.status === "succeeded" && data.asset) return data.asset;
throw new Error(`job ${id} ended ${data?.status ?? response.status}`);
}
}
console.log(await finish(process.env.JOB_ID!, 120));
import os
from nolgia import AuthenticatedClient
from nolgia.api.jobs import wait_for_job
from nolgia.models import Job
client = AuthenticatedClient(base_url="https://api.nolgia.ai/v1", token=os.environ["NOLGIA_TOKEN"])
def finish(job_id, timeout):
while True:
response = wait_for_job.sync_detailed(job_id, client=client, timeout_seconds=timeout)
if response.status_code == 408: # the wait window closed while the job was still running
continue
done = response.parsed
if isinstance(done, Job) and done.status == "succeeded":
return done.asset
raise SystemExit(f"job {job_id} {getattr(done, 'status', done)}")
print(finish(os.environ["JOB_ID"], 120))
use nolgia_client::{ApiError, ClientBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ClientBuilder::new("https://api.nolgia.ai/v1")
.bearer_token(std::env::var("NOLGIA_TOKEN")?)
.build()?;
let asset = finish(&client, &std::env::var("JOB_ID")?, 120).await?;
println!("{}", asset.signed_url);
Ok(())
}
/// Long-poll until the job settles. A 408 means the wait window closed while
/// the job was still running: wait again.
async fn finish(client: &nolgia_client::Client, id: &str, timeout: u64)
-> Result<nolgia_client::types::Asset, Box<dyn std::error::Error>> {
loop {
match client.wait_for_job().id(id.parse::<uuid::Uuid>()?).timeout_seconds(timeout).send().await {
Ok(response) => {
let job = response.into_inner();
return match (job.status.as_str(), job.asset) {
("succeeded", Some(asset)) => Ok(asset),
(status, _) => Err(format!("job {id} ended {status}").into()),
};
}
Err(ApiError::ErrorResponse(response)) if response.status() == 408 => continue,
Err(error) => return Err(error.into()),
}
}
}
A successful wait returns the same succeeded Job shown above, including asset; a failed terminal job is also returned as a Job, with failure. The response fields are the Job and JobFailure tables above.
Parameters #
| Parameter | In | Required | Description |
|---|---|---|---|
id |
path | Yes | Job UUID. |
timeout_seconds |
query | No |
The server accepts a wait window up to 900 seconds.
Stream status updates #
Use SSE for status changes without client polling. Mint a ticket using your bearer token, then open the stream with only that ticket. The ticket is single-use and expires after 60 seconds; reconnect by minting another ticket. These TypeScript and Python stream examples print the frames and have not been proven against production.
curl -sS -X POST "https://api.nolgia.ai/v1/jobs/$JOB_ID/sse-ticket" -H "Authorization: Bearer $NOLGIA_TOKEN"
curl -N "https://api.nolgia.ai/v1/jobs/$JOB_ID/sse?ticket=<ticket from the response>"
import { createNolgiaClient } from "@nolgia/sdk";
const nolgia = createNolgiaClient(process.env.NOLGIA_TOKEN!);
const id = process.env.JOB_ID!;
const { data, error } = await nolgia.POST("/jobs/{id}/sse-ticket", {
params: { path: { id } },
});
if (error) throw new Error(`${error.title}: ${error.detail ?? ""}`);
const response = await fetch(`https://api.nolgia.ai/v1/jobs/${id}/sse?ticket=${encodeURIComponent(data.ticket)}`);
if (!response.ok || !response.body) throw new Error(`stream HTTP ${response.status}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
process.stdout.write(value);
}
import os
import httpx
from nolgia import AuthenticatedClient
from nolgia.api.jobs import create_job_sse_ticket
from nolgia.models import SSETicket
client = AuthenticatedClient(base_url="https://api.nolgia.ai/v1", token=os.environ["NOLGIA_TOKEN"])
job_id = os.environ["JOB_ID"]
ticket = create_job_sse_ticket.sync(job_id, client=client)
if not isinstance(ticket, SSETicket):
raise SystemExit(f"refused: {ticket}")
with httpx.stream("GET", f"https://api.nolgia.ai/v1/jobs/{job_id}/sse", params={"ticket": ticket.ticket}, timeout=None) as response:
response.raise_for_status()
for line in response.iter_lines():
print(line)
The ticket response is 201 Created:
{
"ticket": "…",
"expires_at": "2026-09-21T03:34:15.423881465Z"
}
| Field | Type | Required | Description |
|---|---|---|---|
ticket |
string | Yes | Opaque single-use secret to pass as the ticket query parameter. |
expires_at |
string | Yes | Instant after which the ticket can no longer be redeemed. |
The stream answers 200 with Content-Type: text/event-stream. These are the three captured production frames, with the signed URL shortened:
event: status
data: {"status":"queued"}
event: status
data: {"status":"running","progress":0}
event: complete
data: {"status":"succeeded","progress":0,"asset_url":"https://storage.googleapis.com/nolgia-generations-prod/dad27b53-a85b-4…"}
| Event | Fields | Meaning |
|---|---|---|
status |
status, optional progress |
Current state immediately, then updates while queued or running; progress is a number from 0 to 1 when known. |
complete on success |
status: succeeded, optional progress, asset_url when available |
Terminal success; the stream closes. asset_url may be a gs:// URI or an expired signed URL. Fetch GET /jobs/{id} or GET /assets/{id} with bearer authentication for a fresh asset signed_url before downloading. |
complete on failure |
status: failed, optional error, failure_kind, failure_code, credits_refunded |
Terminal failure; read the typed failure and recorded refund result. |
complete on cancellation |
status: canceled, optional error (the plain statement of what the cancel did), credits_refunded once the hold settles |
The job was canceled with POST /jobs/{id}/cancel; read GET /jobs/{id} for the full cancellation. |
Parameters #
| Parameter | In | Required | Description |
|---|---|---|---|
id |
path | Yes | Job UUID. |
| Parameter | In | Required | Description |
|---|---|---|---|
id |
path | Yes | Job UUID. |
ticket |
query | Yes | Single-use ticket from POST /jobs/{id}/sse-ticket. |
Read Streaming for the full stream examples and agent session events.
Get the result #
A succeeded job embeds its asset, shown in the succeeded response above. Download asset.signed_url; metadata describes the returned media, and enhanced_prompt records the composed prompt when available.
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | |
enhanced_prompt |
string, nullable | No | Image generations only: the server-composed prompt that was actually rendered, present only when it differs from prompt (the Aura layer enhanced the prompt, or a character's or element's canonical description was folded in).… |
signed_url |
string | Yes | Time-limited GCS signed URL for download.… |
expires_at |
string | Yes | Expiry of signed_url. |
mime_type |
string | No | |
width |
integer, nullable | No | |
height |
integer, nullable | No | |
duration_seconds |
number, nullable | No | Media duration in seconds for video/audio assets.… |
has_audio |
boolean, nullable | No | Whether the asset's media carries an audio stream.… |
thumbnail_url |
string, nullable | No | Time-limited signed URL for a server-generated thumbnail (image downscale or video poster frame).… |
Cancel a request #
POST /jobs/{id}/cancel cancels a queued or running job. The job moves to canceled at once and is never delivered: no asset is added to your library, even if the model provider finishes the render afterwards. The response is the job, and its cancellation object says plainly what happened:
| Where the job was | What Nolgia does | Credits |
|---|---|---|
Not yet at the provider (stage: before_submit): queued, or waiting for a provider slot |
Nothing is sent | Refunded in full now (settlement: refunded) |
At the provider, and the provider stops it before it starts (provider_cancel: cancelled) |
Asks the provider to stop it | Refunded in full now |
At the provider, and the provider stops it part way and bills only what it rendered (stopped_partway) |
Asks the provider to stop it | The rendered share is charged, the rest refunded (partially_refunded) |
At the provider, and it cannot be stopped (requested, refused, unsupported, failed) |
Asks the provider to stop it where it can, then waits for its final answer | settlement: pending until then: refunded if the provider stops or fails the render without billing, charged if it finishes and bills |
cancellation.message (also served as status_message) is the customer-facing sentence; show it as written. It never claims a refund before the ledger has made it, and a pending settlement is rewritten when the provider answers. credits_refunded and credits_charged carry the amounts once settled.
Canceling twice returns the same record (200). A job that already finished, or whose finished result is already being delivered, answers 409 with code: job_not_cancellable. Another account's job is a 404. In an organization, members may cancel their own jobs and owners and admins any job; viewers and billing contacts get 403. An agent credential may cancel what its owner may.
| Parameter | In | Required | Description |
|---|---|---|---|
id |
path | Yes | Job UUID. |
To stop an agent turn instead, use POST /agent/sessions/{id}/interrupt; see Agent Sessions API.
List your jobs #
Use GET /jobs to recover accepted work or group generations by their agent session. The response is a JobPage with items, total, and an optional next_cursor.
| Parameter | In | Required | Description |
|---|---|---|---|
limit |
query | No | Maximum items to return. |
cursor |
query | No | Opaque pagination cursor returned by a prior response. |
status |
query | No | |
modality |
query | No | |
agent_session_id |
query | No | Return only jobs whose agent turn ran in the given chat session (jobs.agent_session_id, stamped at submit). Scoped to the caller's own jobs. Jobs created before this attribution shipped, and generations launched outside an agent turn, are not matched. |
curl --fail-with-body -sS "https://api.nolgia.ai/v1/jobs?status=running&modality=video" \
-H "Authorization: Bearer $NOLGIA_TOKEN"
| Field | Type | Required | Description |
|---|---|---|---|
items |
array of Job |
Yes | |
next_cursor |
string, nullable | No | |
total |
integer | Yes | Total number of jobs matching the status/modality filter, ignoring pagination. |
Duplicate submissions and Idempotency-Key #
The same body with the same Idempotency-Key, or with no key both times, inside five minutes returns 409 Conflict and names the earlier job_id. A different key deliberately starts another generation and returns 202. A 409 is never billed.
curl --fail-with-body -sS https://api.nolgia.ai/v1/generate/image \
-H "Authorization: Bearer $NOLGIA_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: image-take-2" \
-d '{"model":"flux-pro","prompt":"a paper-cut mountain range at dawn"}'
nolgia --idempotency-key image-take-2 gen image --model flux-pro --prompt "a paper-cut mountain range at dawn" --no-wait
import { createNolgiaClient } from "@nolgia/sdk";
const nolgia = createNolgiaClient(process.env.NOLGIA_TOKEN!, undefined, { headers: { "Idempotency-Key": "image-take-2" } });
const { data: image, error } = await nolgia.POST("/generate/image", {
body: { model: "flux-pro", prompt: "a paper-cut mountain range at dawn", num_images: 1 },
});
if (error) throw new Error(`${error.title}: ${error.detail ?? ""}`);
console.log(image.id);
import os
from nolgia import AuthenticatedClient
from nolgia.api.generate import generate_image
from nolgia.models import GenerateImageRequest, ImageModel, Job
client = AuthenticatedClient(base_url="https://api.nolgia.ai/v1", token=os.environ["NOLGIA_TOKEN"], headers={"Idempotency-Key": "image-take-2"})
job = generate_image.sync(client=client, body=GenerateImageRequest(model=ImageModel("flux-pro"), prompt="a paper-cut mountain range at dawn"))
if not isinstance(job, Job):
raise SystemExit(f"refused: {job}")
print(job.id)
use nolgia_client::{types, ClientBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = ClientBuilder::new("https://api.nolgia.ai/v1")
.bearer_token(std::env::var("NOLGIA_TOKEN")?)
.idempotency_key("image-take-2")
.build()?;
let prompt: types::GenerateImageRequestPrompt = "a paper-cut mountain range at dawn".parse()?;
let job = client.generate_image()
.body_map(|b| b.model("flux-pro").prompt(Some(prompt)).num_images(1u64))
.send().await?.into_inner();
println!("{}", job.id);
Ok(())
}
The duplicate response from production:
{
"detail": "this exact request was already submitted as job 35b6ff8d-0c78-440b-bdb3-bfe476b56d76 less than 5m0s ago and has not been billed twice — check it with GET /jobs/35b6ff8d-0c78-440b-bdb3-bfe476b56d76. To run it again anyway, resubmit with a different Idempotency-Key header.",
"job_id": "35b6ff8d-0c78-440b-bdb3-bfe476b56d76",
"request_id": "localhost/7bnUUXx7b2-007291",
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
| Field | Type | Required | Description |
|---|---|---|---|
code |
string | No | Machine-readable error code.… |
type |
string | Yes | A URI reference identifying the problem type. |
title |
string | Yes | |
status |
integer | Yes | |
detail |
string | No | |
instance |
string | No | |
job_id |
string | No | The job this refusal points at, so a client can follow it without reading detail.… |
request_id |
string | No |
The supplied comparison capture records an accepted new submission; only the job identity and state fields are shown:
{
"created_at": "2026-09-21T03:33:52.474923Z",
"id": "ecf04b6a-d5a1-4ef6-9afd-22b3c10f8e9e",
"modality": "image",
"model": "flux-pro",
"status": "queued"
}
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | Yes | |
modality |
Modality |
Yes | |
model |
string | Yes | |
status |
JobStatus |
Yes | |
created_at |
string | Yes |
Provider callbacks #
Jobs without a provider callback use a 5-second polling cadence. Callback-enabled video jobs use a 20-second backstop: a verified provider callback wakes an immediate status read, and the poller reconciles if a callback is lost. Kling is the first callback provider. Measured on production, working callbacks reduced reads per render from about 15 to about 3; a broken callback host still settled within about 26 seconds. These measurements are observations, not response-time guarantees.
Read Callbacks and webhooks for the boundary between provider callbacks and customer notifications.
Sets #
POST /generate/set answers 202 with an OutputSet, not a job. Each member runs as its own image job with its own credit hold. Poll GET /sets/{id} to follow the set.
| Status | Meaning |
|---|---|
generating |
Member jobs are still running. |
ready |
All Outputs are ready. |
partial |
Some member jobs failed. |
failed |
All member jobs failed. |
If a later member is refused, accepted members keep running and further members are not submitted. Read problems for the refused labels. An Idempotency-Key on the set endpoint is ignored per member.
Error responses #
A refused request has an HTTP problem body; an accepted job that later fails has failure.code on the job. Do not mistake the HTTP 200 that reads a failed job for a successful generation.
| Where | HTTP status | Code | Action |
|---|---|---|---|
| Invalid generation request | 400 |
validation |
Correct the field, model or unsupported capability. |
| Empty wallet or exceeded credit budget | 402 |
out_of_credits |
Top up or reduce the request's cost. |
| Duplicate body and key | 409 |
No code; job_id identifies the existing job |
Follow that job. |
| Rejected quote confirmation | 422 |
confirmation_rejected |
Get a fresh quote for the exact request. |
| Submit-time content-policy refusal | 422 |
prompt_nsfw or ip_detected |
Change the prompt or references. These codes can also occur later on a failed job. |
| Submit-time upstream refusal or failure | 422, 502 or 500 |
job_failed |
Read the problem and retry safely when appropriate. |
| Concurrency or account quota | 429 |
rate_limit |
Wait before resubmitting; use Retry-After-Reset on quota responses. |
| Wait window elapsed | 408 |
timeout |
Wait again; this does not fail the job. |
| Job reaches its deadline or fails during execution | Job read, not a submit refusal | timeout, job_failed, prompt_nsfw, ip_detected in failure.code |
Inspect failure.message and failure.credits_refunded. |
| Value | Meaning |
|---|---|
out_of_credits |
the wallet cannot pay for the job. Nothing was submitted and nothing was charged. Top up, or submit a cheaper model or fewer seconds. |
rate_limit |
refused for now, not refused outright - the caller's own concurrency ceiling, or the provider's. Retry later; the same request will be accepted. |
prompt_nsfw |
a content filter refused the request or the result it produced, on safety grounds. Editing the prompt or the reference media is the fix. Whether the credits were refunded is failure.credits_refunded, not this code. |
ip_detected |
a content filter refused it for a real person's likeness or a protected work, rather than for safety. The fix is different from prompt_nsfw - change the reference image or the named subject, not the tone of the prompt - which is why it is its own code. |
job_failed |
the job ran and did not produce an asset for any other reason, including a provider error. The default for an unclassified failure. |
timeout |
the job ran past its time budget, or a GET /jobs/{id}/wait returned before the job reached a terminal status. On a failed job the work is over; on a wait the job may still be running. |
validation |
the request itself is not acceptable - a missing or malformed field, a model that does not support the capability asked of it, a duration the model will not render. Nothing was submitted and nothing was charged. Retrying unchanged will fail identically. |
confirmation_rejected |
the cost confirmation gate did not pass. Either a client showed the customer a quote and the customer declined, or a supplied confirmation token was refused. A submit that carries no confirmation token is never refused for this reason. |
canceled |
the job was canceled by its owner (POST /jobs/{id}/cancel), not broken. It is the code the SDK wait helpers raise for a canceled job; cancellation on the job says what the provider did and what was refunded. A canceled job carries no failure. |
job_not_cancellable |
POST /jobs/{id}/cancel refused (409) because the job already finished, or its finished result is already being delivered. The job will reach succeeded or failed on its own; nothing was changed. |
approval_required |
refused with 402 (title Approval Needed) because the generation would take an agent run past the price its customer approved (the credit_ceiling a guided preset's review card showed, sent with the brief). Nothing was submitted and nothing was charged. The detail names the generation's cost and the new run total; the agent must ask the customer to approve that total before it continues, never retry, split the job or switch models to fit. |
run_ended |
refused with 409 (title Run Ended) because the agent run this generation was started for (the turn its turn-scoped credential names) has already ended: the platform failed, swept or stopped the turn, or it finished and delivered its reply. Nothing was submitted and nothing was charged. The agent must stop working on that run, never retry or switch models; the customer's chat already shows how it ended. |

