---
title: "Callbacks and webhooks"
description: "There is no customer webhook today. What shipped is a signed provider callback that wakes the poller; here is what that changes for you and how to be notified now."
---

# Callbacks and webhooks

There is no customer webhook today. What shipped is a signed provider callback that wakes the poller to read a video job's current state; it does not send a notification to your application.

## What the provider callback does

Nolgia creates a per-job signed callback URL at video submission and gives it to the provider. `POST /callbacks/{provider}` verifies the provider signature and the Nolgia job token before waking an immediate read from the media proxy. The callback body is never the result: the normal completion and credit-settlement path handles the fresh read. Kling is the first provider with callbacks.

![A signed provider callback wakes the poller; the app observes completion through wait](../assets/diagrams/callback-wakeup.svg)

<!-- gen:endpoints paths=/callbacks/{provider} -->
| Method | Path | What it does |
| --- | --- | --- |
| [POST](../api/#tag/jobs/post/callbacks/{provider}) | `/callbacks/{provider}` | Receive a render provider's completion callback (a wake-up signal). |
<!-- /gen -->

This is a provider endpoint, not a route your application needs to call. Duplicate callbacks and callbacks for settled jobs are no-ops; the poller continues to reconcile if callbacks stop arriving.

## What it changes for you

| Behavior | Before | With provider callbacks | Evidence |
| --- | --- | --- | --- |
| Status reads per render | About 15 | About 3 with a working callback | Measured on production. |
| Callback-enabled video reconciliation | 5-second polling cadence | Immediate read on callback; 20-second polling backstop | Configuration verified in the poller and measured on production. |
| Broken callback host | Completion depends on polling | Still settles within about 26 seconds in the exercised case | Measured on production; not a latency guarantee. |

Jobs without callbacks keep their 5-second polling cadence. A lower provider-read count does not change your request, job id, result format or cost.

## How to be notified today

### Long-poll

Use this in a worker waiting for the result. `GET /jobs/{id}/wait` returns a terminal `Job`, or `408` when its wait window closes; repeat the wait on the same id. The SDK loops below are copied from the proven Quick Start programs.

```bash tab="curl"
$ curl --fail-with-body -sS "https://api.nolgia.ai/v1/jobs/$JOB_ID/wait?timeout_seconds=120" \
  -H "Authorization: Bearer $NOLGIA_TOKEN"
```

```bash tab="CLI"
$ nolgia wait "$JOB_ID"
```

```ts tab="TypeScript"
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));
```

```python tab="Python"
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))
```

```rust tab="Rust"
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()),
        }
    }
}
```

The terminal response is the same `Job` returned by a normal status read. Here is the captured successful response:

```json
{
  "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"
}
```

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

For `queued`, `running` and failed examples, see [Check status](./jobs.html#check-status).

#### Parameters

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

### Server-sent events

Use this for a live status display without client polling. Mint a single-use ticket with your bearer token, then open the ticket-authenticated stream; terminal work produces a `complete` event. The SDK stream readers below are examples and have not been proven against production. Rust requires `futures-util` and `reqwest`'s `stream` feature.

```bash tab="curl"
$ 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>"
```

```bash tab="CLI"
# A job SSE command is not available in the CLI; use long-poll instead.
$ nolgia wait "$JOB_ID"
```

```ts tab="TypeScript" title="example"
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);
}
```

```python tab="Python" title="example"
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)
```

```rust tab="Rust" title="example"
use std::io::Write;
use futures_util::StreamExt;
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 ticket = client.create_job_sse_ticket().id(id).send().await?.into_inner();
    let response = reqwest::Client::new()
        .get(format!("https://api.nolgia.ai/v1/jobs/{id}/sse"))
        .query(&[("ticket", ticket.ticket)])
        .send().await?.error_for_status()?;
    let mut stream = response.bytes_stream();
    while let Some(chunk) = stream.next().await {
        std::io::stdout().write_all(&chunk?)?;
        std::io::stdout().flush()?;
    }
    Ok(())
}
```

The ticket response:

```json
{
  "ticket": "…",
  "expires_at": "2026-09-21T03:34:15.423881465Z"
}
```

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

The recorded stream:

```text
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

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

<!-- gen:params op=streamJobSSE -->
| Parameter | In | Required | Description |
| --- | --- | --- | --- |
| `id` | path | Yes | Job UUID. |
| `ticket` | query | Yes | Single-use ticket from `POST /jobs/{id}/sse-ticket`. |
<!-- /gen -->

> [!TIP]
> Make terminal-state handling idempotent using the job id: reconnects or repeated reads can show the same completed job. Fetch the job or asset from the authenticated API before using its result URL; never trust a URL from a notification you did not verify through the API.

### Error responses

| Response | Meaning | What to do |
| --- | --- | --- |
| `408 timeout` on wait | Job did not finish in this wait window. | Wait again. |
| `401` on ticket creation | Invalid bearer token. | Replace the token; do not retry unchanged. |
| `404` on ticket creation | Job was not found for the caller. | Check the job id and account context. |
| `401` on the stream | Ticket is absent, expired or consumed. | Mint a new ticket. |
| `403` on the stream | Ticket is for a different job. | Mint a fresh ticket for the correct job; the mismatched request consumed the previous ticket. |
| `503` on either SSE endpoint | Job streaming is unavailable. | Use long-poll. |
| Terminal failed job or `complete` frame | Generation ended with `job_failed`, `timeout`, `prompt_nsfw` or `ip_detected`. | Inspect the typed failure and recorded refund outcome. |

## What a customer webhook would need

Customer webhooks are not offered; [contact us](https://nolgia.ai/contact) to ask for them.

## Next steps

:::cards
- [Asynchronous: submit and poll](./jobs.html): Follow the complete job lifecycle.
- [Streaming](./streaming.html): Read the event contract and reconnect behavior.
- [Reliability](./reliability.html): Understand retries, parking and deadlines.
:::
