---
title: "Synchronous: subscribe"
description: "One call that submits and waits: the helper in the repository, and the same behaviour from the published clients today."
---

# Synchronous: subscribe

The `subscribe` / `submit` helpers, shipping in TypeScript and Python 0.1.2, wrap a generation request and status polling. Today, the published clients provide the same submit-and-wait behaviour through the endpoint calls below.

## Today: submit, then wait

The generation endpoint returns a job immediately. The `finish()` loop holds `GET /jobs/{id}/wait` open, repeats the wait after `408`, and returns the asset when the job succeeds; it reports any other terminal outcome. These are the image programs from the Quick Start, including their client construction, wait loop and error handling.

```bash tab="curl"
$ curl -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"}'
$ curl -sS "https://api.nolgia.ai/v1/jobs/<id from the response>/wait?timeout_seconds=120" \
  -H "Authorization: Bearer $NOLGIA_TOKEN"
$ curl -sS -o first.png "<asset.signed_url from the finished job>"
```

```bash tab="CLI"
$ nolgia gen image --model flux-pro --prompt "a paper-cut mountain range at dawn" --out first.png
```

```ts tab="TypeScript"
import { writeFile } from "node:fs/promises";
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}`);
  }
}

async function download(url: string, file: string) {
  await writeFile(file, Buffer.from(await (await fetch(url)).arrayBuffer()));
}

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 ?? ""}`);
await download((await finish(image.id, 120)).signed_url, "first.png");
console.log("image job", image.id, "-> first.png");
```

```python tab="Python"
import os
import httpx
from nolgia import AuthenticatedClient
from nolgia.api.generate import generate_image, generate_video
from nolgia.api.jobs import wait_for_job
from nolgia.models import GenerateImageRequest, GenerateVideoRequest, ImageModel, Job, VideoModel

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)}")


def download(url, path):
    with open(path, "wb") as f:
        f.write(httpx.get(url).content)


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}")
download(finish(job.id, 120).signed_url, "first.png")
print("image job", job.id, "-> first.png")
```

```rust tab="Rust" title="src/main.rs"
use std::num::NonZeroU64;

use nolgia_client::{types, 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 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();
    let asset = finish(&client, &job.id.to_string(), 120).await?;
    std::fs::write("first.png", reqwest::get(&asset.signed_url).await?.bytes().await?)?;
    println!("image job {} -> first.png", job.id);

    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()),
        }
    }
}
```

The submit call answers `202` with this captured job. The blocking program continues waiting after this response.

```json title="202: 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.622149Z",
  "user_id": "dad27b53-a85b-4e3d-8fd6-b152c803a27c"
}
```

The successful wait returns the finished job and its asset. This is the captured production response with the signed URLs shortened; the fixture's prompt is from its lighthouse run.

```json title="200: 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-4e…",
    "size_bytes": 418584,
    "status": "ready",
    "tags": [],
    "thumbnail_url": "https://storage.googleapis.com/nolgia-generations-prod/dad27b53-a85b-4e…",
    "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"
}
```

<!-- gen:fields schema=Job only=id,status,model,modality,progress,asset,failure,created_at,completed_at -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes |  |
| `modality` | `Modality` | Yes |  |
| `model` | string | Yes |  |
| `status` | `JobStatus` | Yes |  |
| `asset` | `Asset` | No |  |
| `failure` | `JobFailure` | No |  |
| `progress` | number, nullable | No |  |
| `created_at` | string | Yes |  |
| `completed_at` | string, nullable | No |  |
<!-- /gen -->

<!-- gen:fields schema=Asset only=id,signed_url,expires_at,mime_type -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes |  |
| `signed_url` | string | Yes | Time-limited GCS signed URL for download.… |
| `expires_at` | string | Yes | Expiry of `signed_url`. |
| `mime_type` | string | No |  |
<!-- /gen -->

### Parameters reference

The image request below is the source of truth for `model`, `prompt`, and the optional image count. Choose supported values from [Common model arguments](./model-arguments.html).

<!-- gen:fields schema=GenerateImageRequest only=model,prompt,num_images -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `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. |
<!-- /gen -->

<!-- gen:params op=waitForJob -->
| Parameter | In | Required | Description |
| --- | --- | --- | --- |
| `id` | path | Yes | Job UUID. |
| `timeout_seconds` | query | No |  |
<!-- /gen -->

> [!NOTE]
> `408` means the wait window closed while the job was still running. Wait again using the same job id; do not submit a replacement generation.

## The helper (next release)

The following `subscribe` signatures (TypeScript and Python 0.1.2; the Rust crate keeps its own) document the implementation that is already checked in. They poll `GET /jobs/{id}`, then read the job's assets; they do not use the long-poll endpoint shown above. These are signatures and call forms, not imports supported by today's published packages.

```ts tab="TypeScript" title="repository signature"
subscribe(client, endpoint, args, {
  pollInterval, maxPollTime, onStatus, signal, headers,
});
```

```python tab="Python" title="repository signature"
def subscribe(client: AuthenticatedClient, endpoint: str, arguments: dict, *,
              poll_interval: float = 0.5, max_poll_time: float = 1800.0,
              on_status: Optional[Callable[[StatusUpdate], None]] = None,
              headers: Optional[dict[str, str]] = None) -> GenerationResult:
    ...
```

```rust tab="Rust" title="repository call form"
subscribe(&client, endpoint, arguments, SubscribeOptions::default()).await?
```

A successful result contains the terminal job, its job id, a `media` collection, and `url` for the first returned media item. Each media entry identifies an asset and its signed URL. See [Uploads](./uploads.html) for asset access, and download a signed URL before it expires.

| Result field | TypeScript | Python / Rust | Meaning |
| --- | --- | --- | --- |
| Job id | `jobId` | `job_id` | The accepted job's id, reusable outside this process. |
| Terminal job | `job` | `job` | The job returned by the API. |
| Media | `media` | `media` | Assets listed for the job, falling back to the inline asset if the asset list is unavailable. |
| First URL | `url` | `url` | The first media URL, or no value when there are no media entries. |

## Parameters

### TypeScript

The repository helper takes `client`, `endpoint`, `args`, and an optional options object. Durations are in **milliseconds**.

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `client` | `NolgiaClient` | Required | The authenticated typed client. |
| `endpoint` | `GenerateEndpoint` | Required | `/generate/image`, `/generate/audio`, `/generate/video`, `/generate/3d`, or `/restore/video`. |
| `args` | `unknown` | Required | JSON arguments passed unchanged to the endpoint for validation. |
| `pollInterval` | `number` | `500` | Delay between status reads; finite and greater than zero. |
| `maxPollTime` | `number` | `1800000` | Client wait budget; finite and nonnegative. |
| `onStatus` | `(update: StatusUpdate) => void` | Unset | Receives the initial state and changes; callback exceptions propagate. |
| `signal` | `AbortSignal` | Unset | Stops the client's wait when aborted. |
| `headers` | `Record<string, string>` | Unset | Additional headers on the submission request. |

### Python

The repository helper takes `client`, `endpoint`, `arguments`, and keyword-only options. Durations are in **seconds**.

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `client` | `AuthenticatedClient` | Required | The authenticated client. |
| `endpoint` | `str` | Required | The generation endpoint; it must return a job. |
| `arguments` | `dict` | Required | JSON request body. |
| `poll_interval` | `float` | `0.5` | Delay between status reads; finite and greater than zero. |
| `max_poll_time` | `float` | `1800.0` | Client wait budget; finite and nonnegative. |
| `on_status` | `Callable[[StatusUpdate], None]` or `None` | `None` | Receives the initial state and changes; callback exceptions propagate. |
| `headers` | `dict[str, str]` or `None` | `None` | Additional headers on the submission request. |

### Rust

The repository helper takes `&Client`, an endpoint string, a `serde_json::Value` request body and `SubscribeOptions`.

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |
| `client` | `&Client` | Required | The authenticated client. |
| `endpoint` | `&str` | Required | `/generate/image`, `/generate/audio`, `/generate/video`, `/generate/3d`, or `/restore/video`. |
| `arguments` | `serde_json::Value` | Required | JSON request body. |
| `poll_interval` | `Duration` | `Duration::from_millis(500)` | Delay between status reads. |
| `max_poll_time` | `Duration` | `Duration::from_secs(1800)` | Client wait budget. |
| `on_status` | Optional boxed callback | `None` | Receives `&StatusUpdate`; the callback is `Send + Sync`. |
| `headers` | `Vec<(String, String)>` | Empty | Additional headers on the submission request. |

`/generate/set` returns an output set rather than a job and does not use this helper flow. See [Asynchronous: submit and poll](./jobs.html#sets).

> [!NOTE]
> A helper timeout stops waiting, not the generation. The job continues on the server and credits are still spent. To stop the job itself, call the handle's `cancel()` (TypeScript and Python), which calls `POST /jobs/{id}/cancel` and returns the canceled job with its `cancellation`; see [Cancel a request](./jobs.html#cancel-a-request).

## Progress updates

The repository callbacks `onStatus` and `on_status` receive `status` and `progress`, together with the job id, status detail, status message and full job. They run once for the initial submit response and again when one of those status fields changes. Progress may be absent; do not invent a percentage when it is missing.

Today, read the same status information from `GET /jobs/{id}`. A job can remain queued with `status_detail: provider_down` or `upstream_at_capacity`; display `status_message` while it waits. [Streaming](./streaming.html) describes receiving changes without client polling.

<!-- gen:fields schema=Job only=status,status_detail,status_message,progress -->
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `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 |  |
<!-- /gen -->

## When to use

Use a blocking call for a command-line script or a worker that handles one job at a time and needs the finished file before continuing. For a web request or several jobs in parallel, store the accepted job id, return promptly, and follow it from a separate process using [Asynchronous: submit and poll](./jobs.html).

## Error responses

The TypeScript and Python repository helpers raise `NolgiaGenerationError`; Rust returns `GenerationError`. Their `code` preserves the server's problem or failure code, including unknown future codes. The published endpoint calls expose the problem response on submission and `failure.code` on a failed job; keep those two stages separate.

| Outcome | Where to read it | What to do |
| --- | --- | --- |
| Submit refused | HTTP problem `code` and `detail` | Correct the request or follow the code's retry guidance. A refused submit has no new accepted job. |
| Job failed | `failure.code`, `failure.message`, `failure.credits_refunded` | Report the terminal reason and the recorded credit settlement. |
| Wait window closed | HTTP `408` from the wait endpoint | Keep waiting for the existing job. |
| Client wait budget expired | Helper error `timeout` | Read the existing job later; the generation continues. |

<!-- gen:fields schema=JobFailure -->
| 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.… |
<!-- /gen -->

<!-- gen:enum schema=GenerationErrorCode -->
| 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. |
<!-- /gen -->

> [!WARNING]
> A `401` is a missing, bad or expired token. A generation's `402` / `out_of_credits` means the wallet cannot pay for it. Do not retry a `401` with the same token.

## Next steps

:::cards
- [Asynchronous: submit and poll](./jobs.html): Return the job id immediately and follow it from any process.
- [Reliability](./reliability.html): Understand retries, provider outages, deadlines and refunds.
:::
