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 status | Webhook sent |
|---|---|
completed | Yes |
failed | Yes |
nsfw | Yes |
canceled while the render was already running | Yes, when the render ends and its output is discarded |
canceled while still queued | No |
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.
httpsonly. Use anhttps://URL. A plainhttp://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
- Treat the body as a hint. Read
request_idfrom it and ignore the rest. - Re-fetch the status with your key.
GET /v1/generation/requests/{request_id}/statusneeds 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 returns404. - 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. - Answer fast. Return
2xxright 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.