Windpaint
Generation

Job webhooks

Get a POST when a generation job finishes, and handle it safely: what's sent, when, and what isn't guaranteed.

Pass a webhook_url when you submit a job and Windpaint POSTs the job’s status to it once the job finishes. It saves you from polling a video for minutes, but it’s a best-effort notification: unsigned, sent once, and never retried. Use it as a prompt to go and check the job, not as the result itself.

curl -X POST https://api.windpaint.ai/v1/generation/capabilities/video.generate \
  -H "Authorization: Bearer $WINDPAINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "slow dolly in",
    "inputs": {"start_frame": ["8e1f2a3b-4c5d-4e6f-9a0b-1c2d3e4f5a6b"]},
    "webhook_url": "https://example.com/hooks/windpaint"
  }'

With the CLI, pass --webhook-url to windpaint run.

These per-job webhooks are separate from your organization’s account webhooks, which are signed and cover account events (members, API keys, low balance). There are no generation events on account webhooks, and no webhooks for product runs; poll those.

When it fires

One POST, when the job reaches a terminal status:

Final statusWebhook sent
completedYes
failedYes
nsfwYes
canceled while the render was already runningYes, when the render ends and its output is discarded
canceled while still queuedNo

Canceling through the API never triggers the webhook itself; you already have the result in the cancel response. If the job was mid-render, a webhook with status: "canceled" may still arrive later, when the worker finishes and discards the output.

What’s sent

A POST with Content-Type: application/json and the same body GET /v1/generation/requests/{id}/status returns:

{
  "status": "completed",
  "request_id": "3c9a7b1d-2e4f-4a6b-8c0d-1e2f3a4b5c6d",
  "capability": "video.generate",
  "model": "wan-2.2-i2v",
  "project_id": "5b0c7e1a-2f3d-4e5f-8a9b-0c1d2e3f4a5b",
  "source": "api",
  "outputs": [
    {
      "id": "d41e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a",
      "url": "https://api.windpaint.ai/v1/generation/assets/d41e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a/content",
      "content_type": "video/mp4",
      "width": 848,
      "height": 480,
      "duration_s": 5.0
    }
  ],
  "images": [],
  "video": {
    "id": "d41e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a",
    "url": "https://api.windpaint.ai/v1/generation/assets/d41e9f0a-1b2c-4d3e-8f4a-5b6c7d8e9f0a/content",
    "content_type": "video/mp4",
    "width": 848,
    "height": 480,
    "duration_s": 5.0
  },
  "credits": { "estimate": "4.00", "actual": "4.00" },
  "error": null,
  "created_at": "2026-10-04T14:05:40.120Z",
  "finished_at": "2026-10-04T14:09:12.874Z"
}

See Read the status for every field.

Delivery guarantees

  • Best effort, at most once. Windpaint makes one attempt with a 10-second timeout. A timeout, connection error or non-2xx response is not retried.
  • Unsigned. There’s no signature header and no shared secret, so the request alone doesn’t prove it came from Windpaint.
  • https only. Use an https:// URL. A plain http:// URL is accepted at submit but never called.
  • No ordering across jobs. Each job’s webhook is independent.

Because a delivery can be lost, don’t make the webhook the only way your system learns a job is done. Keep the request_ids you’re waiting on and have a fallback that polls the ones that stay open too long.

Handle it safely

  1. Treat the body as a hint. Read request_id from it and ignore the rest.
  2. Re-fetch the status with your key. GET /v1/generation/requests/{request_id}/status needs your API key, so a forged POST can’t make you act on a fake result. If the id isn’t one of yours, the call returns 404.
  3. Be idempotent on request_id. Record which jobs you’ve processed and skip ones you’ve already handled. That covers a webhook arriving after your fallback poll already picked the job up.
  4. Answer fast. Return 2xx right away and do slow work (downloading the output, further jobs) in the background. The sender stops waiting after 10 seconds.

You can also put a hard-to-guess token in the URL (https://example.com/hooks/windpaint?token=...) to drop junk traffic early. It’s a filter, not authentication; still re-fetch the status.

FastAPI

import os

import httpx
from fastapi import BackgroundTasks, FastAPI, Request

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

app = FastAPI()
processed: set[str] = set()  # use your database in production


async def handle(request_id: str) -> None:
    if request_id in processed:
        return
    async with httpx.AsyncClient(timeout=30) as client:
        resp = await client.get(f"{API}/generation/requests/{request_id}/status", headers=HEADERS)
        if resp.status_code == 404:
            return  # not one of ours
        resp.raise_for_status()
        job = resp.json()
    if job["status"] not in TERMINAL:
        return
    processed.add(request_id)
    if job["status"] == "completed":
        for out in job["outputs"]:
            print("ready:", out["id"], out["content_type"])
    else:
        print("ended:", job["status"], job["error"])


@app.post("/hooks/windpaint")
async def windpaint_hook(request: Request, background: BackgroundTasks):
    try:
        body = await request.json()
        request_id = str(body["request_id"])
    except Exception:
        return {"ok": True}
    background.add_task(handle, request_id)
    return {"ok": True}

Express

import express from "express";

const API = "https://api.windpaint.ai/v1";
const HEADERS = { Authorization: `Bearer ${process.env.WINDPAINT_API_KEY}` };
const TERMINAL = new Set(["completed", "failed", "nsfw", "canceled"]);
const processed = new Set(); // use your database in production

async function handle(requestId) {
  if (processed.has(requestId)) return;
  const resp = await fetch(`${API}/generation/requests/${requestId}/status`, { headers: HEADERS });
  if (resp.status === 404) return; // not one of ours
  if (!resp.ok) throw new Error(`status ${resp.status}`);
  const job = await resp.json();
  if (!TERMINAL.has(job.status)) return;
  processed.add(requestId);
  if (job.status === "completed") {
    for (const out of job.outputs) console.log("ready:", out.id, out.content_type);
  } else {
    console.log("ended:", job.status, job.error);
  }
}

const app = express();

app.post("/hooks/windpaint", express.json(), (req, res) => {
  res.sendStatus(204);
  const requestId = req.body?.request_id;
  if (typeof requestId === "string") handle(requestId).catch(console.error);
});

app.listen(3000);

Both receivers acknowledge immediately, then confirm the job with the API before acting on it. Webhook receiver in the cookbook builds this out with persistence and a fallback poller.