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.
How it works
Section titled βHow it worksβ- You register an HTTPS URL on a project (or a team) and choose the events it should receive.
- Scenario returns a signing secret for that endpoint, once.
- Whenever a subscribed event occurs, Scenario sends a
POSTrequest with a JSON body to your URL, signed with that secret. - Your server verifies the signature, answers with a
2xxstatus 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
Section titled βRegister an endpointβFrom the web app
Section titled βFrom the web appβ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.
With the API
Section titled βWith the APIβ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 types
Section titled βEvent typesβProject events
Section titled β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
Section titled β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
Section titled β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 |
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.
{ "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 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} 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
Section titled β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-SHA256of<timestamp>.<raw request body>, keyed with your endpoint secret (the wholewhsec_...string).
To verify a request:
- Split the header on the first
.intotimestampandsignature. - Reject the request if
timestampis more than 5 minutes old. This blocks replayed requests. - Compute
HMAC-SHA256(secret, timestamp + "." + rawBody)and hex-encode it. - Compare it with
signatureusing 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)});import hashlibimport hmacimport jsonimport osimport 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 "", 200Test your implementation
Section titled β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.5d70e7dfcb9ed1a627832c7f8e6152fd1efe1cb87278bd4f562267dc07de9303That timestamp is from 2021, so switch off the 5-minute check for this test only.
Respond and retry
Section titled βRespond and retryβ- Answer with any
2xxstatus (for example200or204) 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 inretrying. - 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 sameidacross 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.completedbeforegeneration.created). When order matters, fetch the current state from the API, such as the jobβsstatus, 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
Section titled β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:
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
Section titled β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:
# List deliveries (paginate with ?paginationToken=...)GET /v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}/events
# One delivery, with its payload and attemptsGET /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
Section titled β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. |