---
title: Webhooks | Scenario Docs
description: Receive a signed HTTP request when a generation, download or training finishes, instead of polling the Jobs API.
---

Most Scenario operations are asynchronous: a generation, an asset export or a model training returns a job right away and finishes later. You can poll the Jobs API until the job settles, or you can register a **webhook endpoint** and let Scenario call your server the moment something happens.

This guide covers how to register an endpoint, which events you can receive, what each request looks like, how to verify its signature, and how deliveries are retried.

## How it works

1. You register an HTTPS URL on a project (or a team) and choose the events it should receive.
2. Scenario returns a **signing secret** for that endpoint, once.
3. Whenever a subscribed event occurs, Scenario sends a `POST` request with a JSON body to your URL, signed with that secret.
4. Your server verifies the signature, answers with a `2xx` status quickly, and processes the event.

There are two kinds of endpoints:

| Owner       | Receives                                                                                            | Managed by                                                       |
| ----------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| **Project** | Job lifecycle events: generations, downloads, model trainings                                       | Project admins                                                   |
| **Team**    | Team alerts, such as a credit threshold being crossed (at most 10 per team; the 11th returns `400`) | Team admins, or an API key with the `team.webhooks.manage` scope |

## Register an endpoint

### From the web app

In [app.scenario.com](https://app.scenario.com), open **Settings** and pick **Webhooks**: under **Project** for job events ([Project Webhooks](https://app.scenario.com/settings/project-webhooks)), under **Organization** for team alerts ([Organization Webhooks](https://app.scenario.com/settings/organization-webhooks)). Click **Add an endpoint**, enter its URL, optionally a description, and select the events. The signing secret is shown once, right after creation.

### With the API

Terminal window

```
curl -X POST "https://api.cloud.scenario.com/v1/projects/{projectId}/webhook-endpoints" \
  -H "Authorization: Basic $(echo -n 'your-api-key:your-api-secret' | base64)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/scenario/webhooks",
    "enabledEvents": ["generation.completed", "generation.failed"],
    "description": "Production job notifications"
  }'
```

| Field           | Type      | Required | Description                                                                                            |
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------ |
| `url`           | string    | Yes      | Absolute URL that receives the events. Use HTTPS.                                                      |
| `enabledEvents` | string\[] | Yes      | Events to subscribe to (see [Event types](#event-types)), or `["*"]` for all of them. Cannot be empty. |
| `description`   | string    | No       | A note to help you recognise the endpoint.                                                             |

The response contains the endpoint, including its `secret`:

```
{
  "webhookEndpoint": {
    "id": "we_V1stGXR8Z5jdHi6BmyT3aB2c",
    "ownerId": "proj_k9Xq2mWfPz7LrT4vYbN8cD1e",
    "ownerType": "project",
    "url": "https://example.com/scenario/webhooks",
    "enabledEvents": ["generation.completed", "generation.failed"],
    "enabled": true,
    "description": "Production job notifications",
    "nbTotalCalls": 0,
    "nbFailedCalls": 0,
    "secret": "whsec_your_endpoint_secret",
    "createdAt": "2026-09-25T10:12:44.901Z",
    "updatedAt": "2026-09-25T10:12:44.901Z"
  }
}
```

The `secret` is returned **only in this creation response**. Store it in your secret manager straight away. It cannot be shown again or rotated: if you lose it, delete the endpoint and create a new one.

Team endpoints use the same body on `POST /v1/teams/{teamId}/webhook-endpoints`.

A URL can be registered only once per project or team; registering it again returns `409 Conflict`.

## Event types

### Project events

| Event                      | Sent when                             |
| -------------------------- | ------------------------------------- |
| `generation.created`       | A generation job is created           |
| `generation.completed`     | A generation job succeeds             |
| `generation.failed`        | A generation job fails                |
| `asset.download.created`   | An asset archive export starts        |
| `asset.download.completed` | An asset archive is ready to download |
| `asset.download.failed`    | An asset archive export fails         |
| `model.download.created`   | A model export starts                 |
| `model.download.completed` | A model export is ready to download   |
| `model.download.failed`    | A model export fails                  |
| `model.training.started`   | A model training starts               |
| `model.training.completed` | A model training succeeds             |
| `model.training.failed`    | A model training fails                |
| `model.training.cancelled` | A model training is cancelled         |

`["*"]` on a project endpoint subscribes it to every project event, including ones added later.

### Team events

| Event                           | Sent when                                                                                                                                                                                                                                                                |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `team.credit.threshold.crossed` | The team’s remaining Creative Units drop below one of its configured credit alert thresholds. Each crossed threshold sends its own event, once per crossing: it re-arms when the balance goes back above it (after a top-up, for example) and resets each billing period |

`["*"]` on a team endpoint subscribes it to every team alert. A project endpoint cannot subscribe to team events, and a team endpoint cannot subscribe to project events: the API answers `422` if you mix them.

## The request

Every delivery is an HTTP `POST` with these headers:

| Header                 | Value                                                                  |
| ---------------------- | ---------------------------------------------------------------------- |
| `Content-Type`         | `application/json`                                                     |
| `User-Agent`           | `Scenario-Webhooks/2.0`                                                |
| `X-SCENARIO-SIGNATURE` | `<timestamp>.<signature>`, see [Verify signatures](#verify-signatures) |

The body always has an `id` (unique per event, prefixed `whevent_`), a `type`, and a `data` object whose shape depends on the event family.

- [Generation](#tab-panel-0-0)
- [Asset or model download](#tab-panel-0-1)
- [Model training](#tab-panel-0-2)
- [Credit alert](#tab-panel-0-3)

```
{
  "id": "whevent_Qm8xT2vLpR4sN7kWbY1cF9dA",
  "type": "generation.completed",
  "data": {
    "jobId": "job_aZ67MsuuyQ5JawJekta3V7yD",
    "jobType": "flux",
    "modelId": "flux.1-dev"
  }
}
```

The payload identifies the job; fetch it with [`GET /v1/jobs/{jobId}`](/api/resources/jobs/methods/retrieve/index.md) to read its status and output asset IDs. `modelId` is present when the job ran a model.

```
{
  "id": "whevent_Hn3pK8wQzT1mR6vXcL2bJ5sE",
  "type": "asset.download.completed",
  "data": {
    "jobId": "job_P4kR9mTzW2xL7nQvB5cY8sHd",
    "downloadUrl": "https://cdn.cloud.scenario.com/assets/...?Policy=...&Signature=...&Key-Pair-Id=..."
  }
}
```

`downloadUrl` is a signed [CDN](/get-started/documentation/content-delivery-network-cdn/index.md) URL. It is only present on `*.completed` events.

```
{
  "id": "whevent_Lw5nB2rTqX8kP1mZvH7cD4sF",
  "type": "model.training.completed",
  "data": {
    "model": {
      "id": "model_7kQwPz2mRtX9vL4nB8cY1sHd",
      "name": "My character",
      "status": "trained",
      "...": "..."
    }
  }
}
```

`data.model` is the model object in its summary form, as in the Models list. Fetch [`GET /v1/models/{modelId}`](/api/resources/models/methods/retrieve/index.md) for full details.

```
{
  "id": "whevent_Rt6mW1qZpK3nX8vL2bJ9cF5s",
  "type": "team.credit.threshold.crossed",
  "version": 1,
  "data": {
    "teamId": "team_Xn4pQ7wRzT2mL9vK5bC1sHd8",
    "thresholdUnit": "percent",
    "thresholdValue": 10,
    "remainingCreativeUnits": 4820,
    "totalPeriodCreativeUnits": 50000
  }
}
```

`thresholdUnit` is `percent` (the value is a percentage of the period’s Creative Units) or `absolute` (the value is a number of remaining Creative Units).

Treat payloads as extensible: new fields and event types may appear, so ignore the ones you don’t handle.

## Verify signatures

Anyone who learns your URL can send it requests, so check every delivery before trusting it. The `X-SCENARIO-SIGNATURE` header has two parts separated by a dot:

```
X-SCENARIO-SIGNATURE: 1625002200000.5d70e7dfcb9ed1a627832c7f8e6152fd1efe1cb87278bd4f562267dc07de9303
                      └─ timestamp ─┘ └──────────────────── signature ─────────────────────────┘
```

- **timestamp**: when the request was signed, in Unix **milliseconds**.
- **signature**: the hex-encoded `HMAC-SHA256` of `<timestamp>.<raw request body>`, keyed with your endpoint secret (the whole `whsec_...` string).

To verify a request:

1. Split the header on the first `.` into `timestamp` and `signature`.
2. Reject the request if `timestamp` is more than **5 minutes** old. This blocks replayed requests.
3. Compute `HMAC-SHA256(secret, timestamp + "." + rawBody)` and hex-encode it.
4. Compare it with `signature` using a **constant-time** comparison.

Sign the **raw bytes** you received, not a parsed and re-serialised copy. Re-encoding the JSON changes spacing or key order and the signature will never match. Most frameworks parse the body for you, so ask for the raw body explicitly, as in the examples below.

- [Node.js (Express)](#tab-panel-1-0)
- [Python (Flask)](#tab-panel-1-1)

```
import crypto from 'node:crypto';
import express from 'express';


const SECRET = process.env.SCENARIO_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_MS = 5 * 60 * 1000;


function verifyScenarioSignature(rawBody, header, secret) {
  if (!header) return false;
  const dot = header.indexOf('.');
  if (dot === -1) return false;
  const timestamp = header.slice(0, dot);
  const signature = header.slice(dot + 1);


  if (Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) return false;


  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');


  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}


const app = express();


// express.raw keeps the body as a Buffer, exactly as it was sent.
app.post('/scenario/webhooks', express.raw({ type: '*/*' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyScenarioSignature(rawBody, req.get('X-SCENARIO-SIGNATURE'), SECRET)) {
    return res.sendStatus(400);
  }


  const event = JSON.parse(rawBody);
  res.sendStatus(200); // acknowledge first...
  handleEvent(event); // ...then do the work (ideally on a queue)
});
```

```
import hashlib
import hmac
import json
import os
import time


from flask import Flask, abort, request


SECRET = os.environ["SCENARIO_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_MS = 5 * 60 * 1000


app = Flask(__name__)




def verify_scenario_signature(raw_body: bytes, header: str | None, secret: str) -> bool:
    if not header or "." not in header:
        return False
    timestamp, signature = header.split(".", 1)
    if not timestamp.isdigit():
        return False
    if abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
        return False


    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)




@app.post("/scenario/webhooks")
def scenario_webhook():
    raw_body = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_scenario_signature(raw_body, request.headers.get("X-SCENARIO-SIGNATURE"), SECRET):
        abort(400)


    event = json.loads(raw_body)
    handle_event(event)  # keep this fast, or hand it to a queue
    return "", 200
```

### Test your implementation

Run your verification function against this known-good example before going live. With the secret `my-secret-key`, the timestamp `1625002200000` and this exact body (no spaces):

```
{"id":"whevent_test","type":"generation.completed","data":{"jobId":"job_test","jobType":"flux","modelId":"flux.1-dev"}}
```

the header must be:

```
1625002200000.5d70e7dfcb9ed1a627832c7f8e6152fd1efe1cb87278bd4f562267dc07de9303
```

That timestamp is from 2021, so switch off the 5-minute check for this test only.

## Respond and retry

- **Answer with any `2xx` status** (for example `200` or `204`) as soon as the signature checks out. Any other status or a network error counts as a failed attempt.
- **Answer well within 30 seconds.** An attempt that runs out of time is retried, but it may not appear in the delivery’s `attempts`, and the delivery can stay in `retrying`.
- **Do the work afterwards.** Put the event on a queue, or process it after you respond, so a slow download or database write does not turn into a timeout.
- **Failed deliveries are retried**, up to **3 attempts** in total: a retry about 1 minute after the first failure, then another about 2 minutes later. Each attempt is signed again with a fresh timestamp, so a retry always passes the 5-minute check.
- **Deduplicate on `id`.** An event keeps the same `id` across retries, and in rare cases you may receive it twice. Record the IDs you have processed and skip repeats.
- **Don’t rely on ordering.** Events are delivered in parallel and can arrive out of order (for example, `generation.completed` before `generation.created`). When order matters, fetch the current state from the API, such as the job’s `status`, rather than trusting arrival order.
- **Don’t rely on webhooks alone.** After the last failed attempt the event is not sent again. For critical flows, keep a periodic reconciliation that polls the jobs you are still waiting on.

## Manage endpoints

| Action             | Request                                                                 |
| ------------------ | ----------------------------------------------------------------------- |
| List endpoints     | `GET /v1/projects/{projectId}/webhook-endpoints`                        |
| Update an endpoint | `PUT /v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}`    |
| Delete an endpoint | `DELETE /v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}` |

The same routes exist under `/v1/teams/{teamId}/webhook-endpoints` for team endpoints.

`PUT` accepts any of `url`, `enabledEvents`, `description` and `enabled`. Setting `"enabled": false` pauses deliveries while keeping the endpoint and its secret, which is handy during maintenance. Events that happen while an endpoint is paused are not sent later:

Terminal window

```
curl -X PUT "https://api.cloud.scenario.com/v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}" \
  -H "Authorization: Basic $(echo -n 'your-api-key:your-api-secret' | base64)" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'
```

Each endpoint also reports `nbTotalCalls` and `nbFailedCalls`, a quick health indicator. They count delivery attempts, not events (a delivery that fails three times adds 3 to both), and never reset.

## Inspect deliveries

Every delivery is logged for 90 days. List an endpoint’s recent events, newest first, then open one to see its payload and each attempt:

Terminal window

```
# List deliveries (paginate with ?paginationToken=...)
GET /v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}/events


# One delivery, with its payload and attempts
GET /v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}/events/{eventId}
```

```
{
  "webhookEvent": {
    "id": "whevent_Qm8xT2vLpR4sN7kWbY1cF9dA",
    "endpointId": "we_V1stGXR8Z5jdHi6BmyT3aB2c",
    "type": "generation.completed",
    "status": "success",
    "payload": { "...": "..." },
    "attempts": [
      { "responseStatusCode": 200, "sentAt": "2026-09-25T10:14:02.118Z" }
    ],
    "createdAt": "2026-09-25T10:14:01.874Z",
    "sentAt": "2026-09-25T10:14:02.118Z",
    "updatedAt": "2026-09-25T10:14:02.301Z"
  }
}
```

`status` is one of `created` (queued), `retrying`, `success` or `failed`. Each entry in `attempts` records the status code your server answered (for example `404` or `503`), which is usually the fastest way to see why a delivery failed. A successful attempt is always recorded as `200`, whatever `2xx` your server sent. A `500` can also mean the connection itself failed (DNS, TLS or network), and `-1` means an error on Scenario’s side. In the web app, the same log opens from each endpoint’s deliveries panel.

## Troubleshooting

| Symptom                                     | Likely cause                                                                                                                      |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| Signature never matches                     | The body was parsed and re-serialised before hashing, or the secret is missing its `whsec_` prefix.                               |
| Signature check fails on some requests      | Your server clock drifts; sync it with NTP. The timestamp is in milliseconds, not seconds.                                        |
| Nothing arrives                             | The endpoint is disabled, not subscribed to the event, or registered on a different project. Cancelled generations send no event. |
| Deliveries stuck on `created` or `retrying` | Your endpoint took longer than 30 seconds to answer, so the attempt was cut off before it could be logged.                        |
| Deliveries show `retrying` then `failed`    | Your endpoint answered with a non-2xx status. Acknowledge first, process after.                                                   |
| `409 Conflict` when registering             | That URL is already registered on this project or team.                                                                           |
| `422` when registering                      | You mixed team events and project events on one endpoint.                                                                         |
