---
title: "Webhooks"
description: "Receive a callback when a job is complete."
url: https://docs.roasos.com/concepts/webhooks
---

# Webhooks

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:

```json
{
  "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](/concepts/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                    |

<Callout type="warn">
  An agent run sends no callback, and an analysis sends no callback. roasOS has no `agent_run.completed` event and no `analysis.completed` event. Poll `GET /v1/agent-runs/{id}` for a run, and `GET /v1/workflows/analyses/{id}/status` for an analysis. Refer to [Runs](/agent/runs) and to [Workflows](/workflows).
</Callout>

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`.

<Callout>
  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.
</Callout>

## 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.

```json
{
  "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`:

```json
{ "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 as `t`, 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:

1. Read `t` from the `X-RoasOS-Signature` header. Make sure that it is recent. A limit of 5 minutes prevents a replay attack.
2. Calculate `HMAC_SHA256(secret, t + "." + rawBody)` as lowercase hex. Use the raw request body and the webhook secret of your key.
3. Compare the result with the `v1` value. Use a constant-time comparison.

```javascript
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));
}
```

<Callout type="warn">
  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.
</Callout>

## 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.

```json
{
  "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.
