Windpaint
Generation

Overview

How a generation request works: capabilities, the request body, the async job lifecycle, polling, cancellation and the errors you'll see at submit.

Every generation in Windpaint is an asynchronous job. You ask for a capability, such as image.generate or video.generate, optionally name the model that should run it, and get back a request_id straight away. The job runs in the background; when it finishes, its outputs are stored as assets in your project and listed on the request’s status.

curl -X POST https://api.windpaint.ai/v1/generation/capabilities/image.generate \
  -H "Authorization: Bearer $WINDPAINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "a lighthouse at dusk, film grain"}'
{
  "status": "queued",
  "request_id": "0f6c1d2e-8a3b-4c5d-9e7f-1a2b3c4d5e6f",
  "status_url": "https://api.windpaint.ai/v1/generation/requests/0f6c1d2e-8a3b-4c5d-9e7f-1a2b3c4d5e6f/status",
  "cancel_url": "https://api.windpaint.ai/v1/generation/requests/0f6c1d2e-8a3b-4c5d-9e7f-1a2b3c4d5e6f/cancel",
  "credits_estimate": "0.08"
}

There is no synchronous mode. You either poll status_url until the job reaches a terminal status, or pass a webhook_url and wait for a POST.

Capabilities and models

A capability is a typed operation: what it takes (a prompt, and zero or more input assets in named slots) and what it produces (an image, a video, and so on). A model is one implementation of a capability, with its own resolutions, durations and prices.

You address the capability in the URL and may name a model in the body. Leave model out and Windpaint uses the capability’s first available model. Two capabilities have a live model today: image.generate (z-image-turbo) and video.generate (wan-2.2-i2v). The rest are defined and listed by the API but can’t be submitted yet. See Models for the full table and current prices.

There is also a model-addressed form, POST /v1/generation/models/{model}, which takes image_urls (a list of asset ids or API URLs that fill the model’s main input slot) instead of inputs. It runs the same job. The capability form is the one these docs use.

Submit a request

curl
curl -X POST https://api.windpaint.ai/v1/generation/capabilities/image.generate \
  -H "Authorization: Bearer $WINDPAINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "z-image-turbo",
    "prompt": "a lighthouse at dusk, film grain",
    "resolution": "1k",
    "aspect_ratio": "16:9",
    "seed": 42
  }'

A successful submit returns 202 Accepted with the body shown above. The body is not wrapped in data; submit, status and cancel all return bare objects.

statusstring

Always queued on submit.

request_idstring

The job’s id. Use it with the status and cancel endpoints.

status_urlstring

GET this to read the job’s status.

cancel_urlstring

POST to this to cancel the job.

credits_estimatestring

The price of this job in credits, as a decimal string. It’s fixed at submit and held against your balance until the job finishes.

Request body

Every field is optional except where a capability requires it. Fields the chosen model doesn’t support are rejected with 422, not ignored.

modelstring

The model to run, e.g. z-image-turbo. Defaults to the capability’s first available model. See default model selection.

promptstringdefault:""

What to generate. Required for every capability except media.upscale and video.lipsync. An empty or whitespace-only prompt on a capability that needs one returns 422.

inputsobject

Input assets by slot name, each a list: {"start_frame": ["<asset id>"]}. Values are asset ids, or asset URLs this API returned (an upload’s url, or an output’s url). External URLs are rejected, never fetched. Each asset must be in the same project as the job and of a kind the slot accepts. The slots a capability has, and how many assets each takes, are listed by GET /v1/generation/capabilities.

resolutionstring

The resolution tier: 1k or 2k for images, 480p for video. Defaults to the model’s default tier. See resolution tiers.

aspect_ratiostring

One of 1:1, 4:3, 3:4, 3:2, 2:3, 16:9, 9:16, 21:9, 4:5, 5:4. Defaults to 16:9 for video and 1:1 for everything else.

qualitystring

Reserved for models with quality tiers. No current model has any, so sending a value returns 422.

durationinteger

Clip length in seconds, for models that take one. wan-2.2-i2v takes 5 only, which is also the default. Sending a duration to an image model returns 422.

seedinteger

Random seed. The same seed, prompt and settings on the same model give you the same output, which is how you reproduce a result. Omit it for a random seed; the API doesn’t report which seed it used, so pass one whenever you might want to reproduce the output.

webhook_urlstring

An https URL that receives one POST with the job’s status when it finishes. See Webhooks.

project_idstring

The project to run in, by id. Takes precedence over the X-Windpaint-Project header. See projects.

Each request produces one output: one image for image.generate, one clip for video.generate. To get several variations, submit several requests with different seeds.

Job lifecycle

Submit

POST /v1/generation/capabilities/{capability} validates the request, prices it, holds the credits and returns 202 with status: "queued".

Run

A worker picks up the job and moves it to in_progress. Images usually take seconds. Video takes several minutes.

Finish

The job lands in one terminal status. On completed, the held credits are charged and the outputs are stored in your project. Every other terminal status releases the hold in full, so failed jobs cost nothing.

StatusMeaningTerminalCredits
queuedAccepted, waiting for a workerNoHeld
in_progressRenderingNoHeld
completedAt least one output was storedYesCharged
failedThe render failed or produced no output; see errorYesReleased
nsfwThe model refused the request on content groundsYesReleased
canceledCanceled before it finishedYesReleased

Failure messages

A failed job’s error is one of these fixed messages. The same text appears in job webhooks and, prefixed with the step key, on a failed product run.

errorMeaning
Generation failed upstream (HTTP <status>). Credits were released.The model backend answered with an HTTP error, for example HTTP 500.
Generation failed: the model backend is unavailable. Credits were released.The model backend couldn’t be reached or kept timing out after retries.
Generation failed: the model backend could not complete the request. Credits were released.The model backend rejected or couldn’t finish the request.
Generation failed: the model returned no output. Credits were released.The render finished without producing a file.
Generation failed due to an internal error. Credits were released.Something failed on Windpaint’s side.

All of them are safe to resubmit once; nothing was charged. If the same request keeps failing, try another model or contact support with the request_id.

Read the status

GET /v1/generation/requests/{request_id}/status returns the job. It finds the job anywhere in your organization, so it works without a project header.

{
  "status": "completed",
  "request_id": "0f6c1d2e-8a3b-4c5d-9e7f-1a2b3c4d5e6f",
  "capability": "image.generate",
  "model": "z-image-turbo",
  "project_id": "5b0c7e1a-2f3d-4e5f-8a9b-0c1d2e3f4a5b",
  "source": "api",
  "outputs": [
    {
      "id": "d77a4c1e-9b2f-4a3d-8e6f-7a8b9c0d1e2f",
      "url": "https://api.windpaint.ai/v1/generation/assets/d77a4c1e-9b2f-4a3d-8e6f-7a8b9c0d1e2f/content",
      "content_type": "image/png",
      "width": 1024,
      "height": 576,
      "duration_s": null
    }
  ],
  "images": [
    {
      "id": "d77a4c1e-9b2f-4a3d-8e6f-7a8b9c0d1e2f",
      "url": "https://api.windpaint.ai/v1/generation/assets/d77a4c1e-9b2f-4a3d-8e6f-7a8b9c0d1e2f/content",
      "content_type": "image/png",
      "width": 1024,
      "height": 576,
      "duration_s": null
    }
  ],
  "video": null,
  "credits": { "estimate": "0.08", "actual": "0.08" },
  "error": null,
  "created_at": "2026-10-04T14:02:11.418Z",
  "finished_at": "2026-10-04T14:02:15.902Z"
}
statusstring

One of the statuses above.

capabilitystring

The capability that ran.

modelstring

The model that ran, including when you left model out.

project_idstring

The project the job and its outputs belong to.

sourcestring | null

Where the job came from: api (an API key), studio (the dashboard) or workflow (a step of a product run).

outputsMediaRef[]

Every asset the job produced, of any kind. Empty until the job completes.

imagesMediaRef[]

The image entries of outputs, as a shortcut.

videoMediaRef | null

The first video entry of outputs, as a shortcut.

creditsobject

estimate is the price fixed at submit. actual is what was charged: equal to estimate once the job completes, null otherwise.

errorstring | null

When the job failed, one of the failure messages. null otherwise.

created_atstring

When the job was submitted (ISO 8601).

finished_atstring | null

When the job reached a terminal status.

GET /v1/generation/requests?limit=50 lists the jobs in the current project, newest first, as {"data": [ ... ]} with the same object per job. limit takes 1 to 200.

Poll until done

Start polling a second or two after submit and back off, capping the interval around 15 seconds. An image is usually done within the first few polls; a video can take several minutes, so give video loops a generous overall timeout. There are no rate-limit headers to read today, but polling faster than this doesn’t make the job finish sooner.

Python
import os
import time
import requests

API = "https://api.windpaint.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['WINDPAINT_API_KEY']}"}
TERMINAL = {"completed", "failed", "nsfw", "canceled"}


def wait(request_id: str, timeout_s: float = 1200) -> dict:
    delay, deadline = 1.5, time.monotonic() + timeout_s
    while True:
        resp = requests.get(f"{API}/generation/requests/{request_id}/status", headers=HEADERS)
        resp.raise_for_status()
        job = resp.json()
        if job["status"] in TERMINAL:
            return job
        if time.monotonic() > deadline:
            raise TimeoutError(f"{request_id} still {job['status']}")
        time.sleep(delay)
        delay = min(delay * 1.5, 15)


job = wait("0f6c1d2e-8a3b-4c5d-9e7f-1a2b3c4d5e6f")
if job["status"] != "completed":
    raise RuntimeError(f"{job['status']}: {job['error']}")
print(job["outputs"][0]["url"])

The CLI’s run and status --wait exit with code 9 when the job ends failed, nsfw or canceled, and 10 when --timeout (15 minutes by default) passes first.

Cancel a job

curl -X POST https://api.windpaint.ai/v1/generation/requests/$REQUEST_ID/cancel \
  -H "Authorization: Bearer $WINDPAINT_API_KEY"

Cancel returns the job’s status object. What it does depends on where the job is:

  • queued: the job is marked canceled and never runs.
  • in_progress: the job is marked canceled right away. A render that’s already running isn’t interrupted, but its output is discarded and never appears in your project.
  • Terminal: nothing changes; you get the job back as it was.

In every case where the job ends canceled, the held credits are released in full. The CLI equivalent is windpaint cancel <request-id>.

Projects

Every job runs inside one project, and its outputs are stored there. The project is picked in this order:

  1. project_id in the request body.
  2. The X-Windpaint-Project header, by id or slug.
  3. Your organization’s default project.
curl -X POST https://api.windpaint.ai/v1/generation/capabilities/image.generate \
  -H "Authorization: Bearer $WINDPAINT_API_KEY" \
  -H "X-Windpaint-Project: launch-video" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "a paper boat in the rain"}'

The CLI sends --project, WINDPAINT_PROJECT or the project saved with windpaint projects select as that header. Listing requests and assets is scoped to the project the same way. Submitting into an archived project returns 409, and naming a project outside your organization returns 404. Credits come from the organization’s balance whichever project you use. See Organizations and projects.

Retries and double billing

The API has no idempotency keys. If you retry a submit after a timeout or a dropped connection, and the first request actually reached the server, you get two jobs and pay for both.

To retry safely, check before you resubmit. GET /v1/generation/requests lists the project’s recent jobs newest first; if a job with your capability, model and created_at around the time of your first attempt is already there, poll that one instead.

from datetime import datetime, timedelta, timezone

sent_at = datetime.now(timezone.utc) - timedelta(seconds=5)  # recorded just before the first attempt

# ... first attempt timed out ...

recent = requests.get(f"{API}/generation/requests?limit=20", headers=HEADERS).json()["data"]
already = [
    r for r in recent
    if r["capability"] == "image.generate"
    and datetime.fromisoformat(r["created_at"].replace("Z", "+00:00")) >= sent_at
]
if already:
    job = wait(already[0]["request_id"])

Jobs don’t echo the prompt back, so if you submit many similar jobs at once, record each request_id as soon as you get it and match on time rather than retrying blindly.

Errors at submit

Submit validates everything before it creates a job, so a request that’s rejected costs nothing. Errors use the standard envelope:

{
  "error": {
    "code": "billing.insufficient_credits",
    "message": "Not enough credits: this needs 4.00 and 1.20 are available.",
    "details": { "required": "4.00", "available": "1.20" },
    "request_id": "86926749.37e5a9"
  }
}
StatuscodeWhen
402billing.insufficient_creditsYour organization’s available balance can’t cover credits_estimate. details has required and available. Nothing is held.
422request.validation_failedThe request doesn’t fit the capability or model: an unknown capability or model, a missing prompt, the wrong number of assets in a slot, an unsupported resolution, aspect ratio, duration or quality, an external URL in inputs, or an input asset that isn’t in this project or is the wrong kind. Also returned for a capability with no model yet (“No model implements … yet”). The message says which.
503request.service_unavailableThe model is temporarily unavailable, or the requested tier has no price and can’t be sold. Retry later or pick another tier; GET /v1/generation/capabilities shows available: false for these.

A body that isn’t valid JSON or has a field of the wrong type returns 400 with request.validation_failed. Authentication errors (401, 403) use a different body; see Errors for the full list.