Webhooks
Receive a callback when a job is complete.
A webhook is an alternative to a poll. roasOS sends a POST request to your server when a job reaches a terminal state.
Set up a webhook
Send a webhook_url in the confirm request of a model endpoint:
{
"quote_id": "q_7Hk...",
"prompt": "...",
"webhook_url": "https://your-app.com/roasos/webhook"
}The URL must use https. Its length must be 4,096 characters or less. When the job is complete, roasOS sends generation.completed or generation.failed to that URL.
Which requests take a webhook_url
A callback belongs to a job. A request that makes a job takes webhook_url. A request that makes something else does not.
| Request | Takes webhook_url |
|---|---|
Each model endpoint, such as POST /v1/image/nano-banana-pro | yes |
POST /v1/scenes | yes |
POST /v1/voices | yes |
| The five assembly operations | yes |
POST /v1/workflows/recast | yes |
POST /v1/workflows/static-to-video | yes |
Each item of a POST /v1/generation-batches/quotes request | yes, one URL per item |
POST /v1/workflows/analyze, POST /v1/workflows/extract-brand | no |
POST /v1/workflows/remix, POST /v1/workflows/statics | no |
POST /v1/agent/completions | no |
POST /v1/products/{id}/kit | no |
A batch carries its callbacks one level down. Put a webhook_url on each item of the batch quote. Each item then makes its own job, and each job delivers its own callback.
A workflow that makes a job — recast and static-to-video — delivers the callback of that job. A workflow that makes an agent run or an analysis has no job to deliver, so it takes no webhook_url.
A job that you cancel before it reaches the provider does not send a callback yet. Read GET /v1/jobs/{id} for the terminal state of a canceled job.
roasOS refuses a URL that it cannot deliver
roasOS examines the webhook_url when you create the job. The refusal comes before the quote and before the credits, so a refused request costs you nothing.
| Status | Code | Cause |
|---|---|---|
400 | invalid_url | The URL does not use https, or it points at a private or internal address |
400 | webhook_url_not_supported_for_key | The key holds no webhook signing secret |
Each API key that you make holds its own webhook secret, and roasOS signs each delivery with that secret. A key that roasOS makes for its own product holds no secret. roasOS therefore refuses a webhook_url on such a key, in place of signing the delivery with a different key that you cannot verify. Omit webhook_url on that key and poll the job.
The body
The body has an event name and a data object. The data object is the same job envelope that GET /v1/jobs/{id} returns, so your handler can use one parser for both.
{
"event": "generation.completed",
"data": {
"id": "6f1d…",
"kind": "image",
"model": "nano-banana-pro",
"status": "completed",
"credits_reserved": 2,
"created_at": "2026-08-19T11:59:00.000Z",
"completed_at": "2026-08-19T12:00:12.000Z",
"result": { "url": "https://cdn.roasos.com/…/output.png" },
"links": {
"self": "/v1/jobs/6f1d…",
"status": "/v1/jobs/6f1d…/status",
"result": "/v1/jobs/6f1d…/result",
"events": "/v1/jobs/6f1d…/events"
}
}
}A failed job carries error instead of result:
{ "event": "generation.failed", "data": { "…": "…", "status": "failed", "error": { "code": "generation_failed", "message": "Generation failed" } } }Verify each delivery
Each delivery contains three headers.
X-RoasOS-Signature—t=<unix seconds>,v1=<hex>. The algorithm is HMAC-SHA256.X-RoasOS-Timestamp— the same unix seconds ast, for convenience.X-RoasOS-Delivery-Id— a stable identifier. The identifier stays the same for each attempt. Use it to find duplicates.
To verify that roasOS sent the request, do these steps:
- Read
tfrom theX-RoasOS-Signatureheader. Make sure that it is recent. A limit of 5 minutes prevents a replay attack. - Calculate
HMAC_SHA256(secret, t + "." + rawBody)as lowercase hex. Use the raw request body and the webhook secret of your key. - Compare the result with the
v1value. Use a constant-time comparison.
import crypto from "node:crypto";
export function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((part) => part.split("=")),
);
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const given = Buffer.from(String(parts.v1));
return given.length === expected.length
&& crypto.timingSafeEqual(given, Buffer.from(expected));
}Calculate the signature from the raw body bytes. Do this before you parse the JSON. If you serialize the JSON again, the bytes change and the verification fails.
Process repeated deliveries
roasOS delivers each event a minimum of one time. You can therefore receive the same event more than one time. Use X-RoasOS-Delivery-Id to find duplicates. Make sure that your handler is idempotent.
roasOS makes three immediate attempts and then hands the delivery to a five-minute reconciler until it succeeds or the attempt budget ends. A delivery attempt has a 10-second timeout, does not follow redirects, and discards your response body.
Read the delivery result
The job records what happened to its own callback. The job envelope carries a webhook object when the delivery settles.
{
"data": {
"id": "6f1d…",
"kind": "image",
"status": "completed",
"webhook": {
"status": "delivered",
"delivered_at": "2026-08-21T12:00:14.000Z",
"attempts": 1,
"last_status": "200"
},
"links": { "…": "…" }
}
}| Field | Meaning |
|---|---|
status | delivered or failed. |
delivered_at / failed_at | The time of the outcome. The object holds one of the two, and status names which. |
attempts | The number of attempts that roasOS made. |
last_status | The last answer of your server. The value is null when your server gave none. |
The object is absent in two conditions: the job carried no webhook_url, or the delivery is not yet settled. One poll of GET /v1/jobs/{id} therefore tells you if your own callback arrived.
GET /v1/jobs/{id}/events gives the same result as a step in the timeline. It carries a webhook_delivered or a webhook_failed event, with the attempt count and the last status in its data object.