Skip to content
Get started
DOCUMENTATION

Webhooks

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.

  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

In app.scenario.com, open Settings and pick Webhooks: under Project for job events (Project Webhooks), under Organization for team alerts (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.

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), 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"
}
}

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

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.

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

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

{
"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} to read its status and output asset IDs. modelId is present when the job ran a model.

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

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.
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)
});

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.

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

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.

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.