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.
Lifecycle
Section titled “Lifecycle”| 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.
Creating a task
Section titled “Creating a task”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.
Idempotency keys
Section titled “Idempotency keys”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:
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.
Polling
Section titled “Polling”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.
Webhooks
Section titled “Webhooks”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.1Content-Type: application/jsonUser-Agent: VelvetAPI-Webhooks/1Velvet-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.idto process each task once.
Verifying signatures
Section titled “Verifying signatures”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.
- Read the raw request body, before any JSON parsing.
- Compute the HMAC of
t + "." + bodyand compare it withv1in constant time. - 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);});import hashlibimport hmacimport time
def verify_velvet_signature(raw_body: bytes, header: str | None, secret: str, tolerance: int = 300) -> bool: if not header: return False timestamp, signatures = "", [] for part in header.split(","): key, _, value = part.strip().partition("=") if key == "t": timestamp = value elif key == "v1": signatures.append(value) if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance: return False expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, signature) for signature in signatures)
# Flask@app.post("/webhooks/velvet")def velvet_webhook(): body = request.get_data() if not verify_velvet_signature(body, request.headers.get("Velvet-Signature"), os.environ["VELVET_WEBHOOK_SECRET"]): abort(400) event = json.loads(body) # ... handle event["type"] and event["data"] return "", 204The SDKs include the same check as verifyWebhook (TypeScript) and verify_webhook (Python), which return the parsed event.
Rotating the secret
Section titled “Rotating the secret”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.
Cancelling
Section titled “Cancelling”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.
Listing tasks
Section titled “Listing tasks”GET /v1/tasks lists your account’s tasks, newest first:
limit: 1 to 100, default 20.before: an ISO 8601 timestamp. Pass thecreated_atof 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.