Skip to content

Tasks and webhooks

Every generation is a task. You create it, VelvetAPI runs it on a GPU, and it ends in one of three final states. Nothing is charged unless it succeeds.

State Meaning
queued Accepted. The input and prompt passed their checks and the price is held from your balance.
running A GPU worker has started the job.
succeeded The images are in outputs. The hold became the charge.
failed No images. error says why, and the hold was released. This includes withheld images and timed-out tasks.
cancelled You cancelled it. The hold was released.

A fast task can go from queued to a final state without you ever seeing running. A task that hasn’t finished 30 minutes after it was created fails and is refunded.

POST /v1/tasks takes a JSON body:

Field Type Required Description
model string yes A model id from Models, such as flux-3-klein.
input object yes The model’s input. Each model page lists its fields.
user string yes Your id for the end user who asked for this, 1 to 128 characters. See strikes.
webhook_url string no An https:// URL that receives the finished task. See Webhooks.

The answer is 202 Accepted with the task. Its fields:

Field Description
id The task id (a UUID).
state queued, running, succeeded, failed or cancelled.
price_usd The price held when the task was accepted and charged if it succeeds.
outputs The images: url, content_type, width and height. Empty until the task succeeds.
error Why the task failed or was cancelled, otherwise null.
meta Run details, such as seed, steps, queue_ms (time waiting for a GPU) and run_ms (time on the GPU). withheld: true means the output safety check withheld the images.
created_at, started_at, finished_at ISO 8601 timestamps.

Output URLs are public but unguessable, served from our CDN, and deleted after 14 days. Copy images you want to keep. To edit an image later, pass its URL in the model’s reference_images while it still exists.

Networks fail, and a retried POST /v1/tasks could otherwise create and charge a second task. Send a unique Idempotency-Key header (up to 200 characters, such as your own order id or a UUID) with every task:

Terminal window
curl https://api.velvetapi.dev/v1/tasks \
-H "Authorization: Bearer $VELVET_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-42" \
-d '{"model": "flux-3-klein", "user": "user-123", "input": {"prompt": "..."}}'

If your account already created a task with that key, the API returns that task with 202 and an Idempotent-Replayed: true header, and charges nothing more. The key alone is matched, not the body, and it’s remembered for as long as the task exists, so use a new key for every new request. Retrying with the same key after a timeout, a network error or a 5xx answer is always safe.

GET /v1/tasks/{id} returns the task. Poll every one or two seconds until the state is final. Most images take a few seconds on a warm GPU; the first task after a quiet period can take longer while a worker starts.

The SDKs’ tasks.wait() polls for you.

Instead of polling, pass webhook_url when you create the task. When the task reaches a final state, VelvetAPI sends a POST to that URL:

POST /webhooks/velvet HTTP/1.1
Content-Type: application/json
User-Agent: VelvetAPI-Webhooks/1
Velvet-Signature: t=1792477865,v1=6f1c0a8d3e...
{
"type": "task.succeeded",
"data": {
"id": "0b9c6c2e-4d3f-4a51-9a8e-2f7d1c3b5a90",
"object": "task",
"state": "succeeded",
"outputs": [{ "url": "https://cdn.velvetapi.dev/o/…/0.jpg", "content_type": "image/jpeg", "width": 1024, "height": 1024 }]
}
}

type is task.succeeded, task.failed or task.cancelled, and data is the full task, exactly as GET /v1/tasks/{id} returns it.

  • Answer quickly with any 2xx. Do slow work after you respond.
  • Retries: any other answer, or no answer, is retried with exponential backoff, up to 10 times over about an hour.
  • Duplicates: a delivery can occasionally arrive twice. Use data.id to process each task once.

Check every webhook before you trust it. The Velvet-Signature header holds a Unix timestamp t and a signature v1: the hex HMAC-SHA256 of the string {t}.{raw request body}, keyed with your webhook secret. Find the secret (whsec_...) on the console’s Overview page.

  1. Read the raw request body, before any JSON parsing.
  2. Compute the HMAC of t + "." + body and compare it with v1 in constant time.
  3. Reject timestamps more than five minutes from your clock, so a captured delivery can’t be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyVelvetSignature(rawBody: string, header: string | undefined, secret: string, toleranceSeconds = 300): boolean {
if (!header) return false;
const parts = header.split(",").map((part) => part.trim().split("="));
const timestamp = parts.find(([key]) => key === "t")?.[1] ?? "";
if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
return parts
.filter(([key]) => key === "v1")
.some(([, signature]) => {
const given = Buffer.from(signature ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: keep the body raw for this route.
app.post("/webhooks/velvet", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verifyVelvetSignature(body, req.get("Velvet-Signature"), process.env.VELVET_WEBHOOK_SECRET!)) return res.sendStatus(400);
const event = JSON.parse(body);
// ... handle event.type and event.data
res.sendStatus(204);
});

The SDKs include the same check as verifyWebhook (TypeScript) and verify_webhook (Python), which return the parsed event.

Rotate the secret from the console’s Overview page if it may have leaked. Every delivery is signed when it’s sent, so from that moment all deliveries, including retries of earlier tasks, use the new secret. There’s no overlap period: update your server as soon as you rotate.

POST /v1/tasks/{id}/cancel stops a task that hasn’t finished and releases its hold. It returns the cancelled task, or 409 task_finished if the task had already reached a final state.

GET /v1/tasks lists your account’s tasks, newest first:

  • limit: 1 to 100, default 20.
  • before: an ISO 8601 timestamp. Pass the created_at of the last task you received to get the next page.

The answer is {"object": "list", "data": [...], "has_more": true}. Keep paging while has_more is true.