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 SDKs or the API
Section titled “With the SDKs or the API”Project endpoints live under client.projects.webhookEndpoints (client.projects.webhook_endpoints in Python), which calls POST /v1/projects/{projectId}/webhook-endpoints:
import Scenario from '@scenario-labs/sdk';
// Reads SCENARIO_SDK_API_KEY and SCENARIO_SDK_API_SECRET from the environment.const client = new Scenario();
const { webhookEndpoint } = await client.projects.webhookEndpoints.create( 'proj_k9Xq2mWfPz7LrT4vYbN8cD1e', { url: 'https://example.com/scenario/webhooks', enabledEvents: ['generation.completed', 'generation.failed'], description: 'Production job notifications', },);
// Store webhookEndpoint.secret now: it is never returned again.from scenario_sdk import Scenario
# Reads SCENARIO_SDK_API_KEY and SCENARIO_SDK_API_SECRET from the environment.client = Scenario()
response = client.projects.webhook_endpoints.create( "proj_k9Xq2mWfPz7LrT4vYbN8cD1e", url="https://example.com/scenario/webhooks", enabled_events=["generation.completed", "generation.failed"], description="Production job notifications",)
# Store response.webhook_endpoint.secret now: it is never returned again.curl -X POST "https://api.cloud.scenario.com/v1/projects/{projectId}/webhook-endpoints" \ -H "Authorization: Basic $(printf '%s:%s' "$SCENARIO_SDK_API_KEY" "$SCENARIO_SDK_API_SECRET" | base64 | tr -d '\n')" \ -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" }}The Python SDK takes the same fields in snake_case (enabled_events).
Team endpoints take the same fields through client.teams.webhookEndpoints.create(teamId, {...}) (client.teams.webhook_endpoints.create(team_id, ...) in Python), which calls 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 client.jobs.retrieve(jobId) (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 it with client.models.retrieve(modelId) (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 | TypeScript SDK | API |
|---|---|---|
| List endpoints | client.projects.webhookEndpoints.list(projectId) |
GET /v1/projects/{projectId}/webhook-endpoints |
| Update an endpoint | client.projects.webhookEndpoints.update(webhookEndpointId, { projectId, ... }) |
PUT /v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId} |
| Delete an endpoint | client.projects.webhookEndpoints.delete(webhookEndpointId, { projectId }) |
DELETE /v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId} |
In Python, the same methods are on client.projects.webhook_endpoints, with project_id as a keyword argument. Team endpoints use client.teams.webhookEndpoints (client.teams.webhook_endpoints) with teamId (team_id) instead, on the routes under /v1/teams/{teamId}/webhook-endpoints.
An update accepts any of url, enabledEvents, description and enabled. Setting enabled to 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:
await client.projects.webhookEndpoints.update('we_V1stGXR8Z5jdHi6BmyT3aB2c', { projectId: 'proj_k9Xq2mWfPz7LrT4vYbN8cD1e', enabled: false,});client.projects.webhook_endpoints.update( "we_V1stGXR8Z5jdHi6BmyT3aB2c", project_id="proj_k9Xq2mWfPz7LrT4vYbN8cD1e", enabled=False,)curl -X PUT "https://api.cloud.scenario.com/v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}" \ -H "Authorization: Basic $(printf '%s:%s' "$SCENARIO_SDK_API_KEY" "$SCENARIO_SDK_API_SECRET" | base64 | tr -d '\n')" \ -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, with events.list, then open one with events.retrieve to see its payload and each attempt. The list is paginated: pass the nextPaginationToken of one page as paginationToken to get the next.
const projectId = 'proj_k9Xq2mWfPz7LrT4vYbN8cD1e';const webhookEndpointId = 'we_V1stGXR8Z5jdHi6BmyT3aB2c';
// Recent deliveries, newest firstconst { items, nextPaginationToken } = await client.projects.webhookEndpoints.events.list(webhookEndpointId, { projectId, });
// One delivery, with its payload and attemptsconst { webhookEvent } = await client.projects.webhookEndpoints.events.retrieve( items[0].id, { projectId, webhookEndpointId, },);project_id = "proj_k9Xq2mWfPz7LrT4vYbN8cD1e"webhook_endpoint_id = "we_V1stGXR8Z5jdHi6BmyT3aB2c"
# Recent deliveries, newest firstpage = client.projects.webhook_endpoints.events.list( webhook_endpoint_id, project_id=project_id,)
# One delivery, with its payload and attemptsevent = client.projects.webhook_endpoints.events.retrieve( page.items[0].id, project_id=project_id, webhook_endpoint_id=webhook_endpoint_id,).webhook_event# List deliveries (paginate with ?paginationToken=...)curl "https://api.cloud.scenario.com/v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}/events" \ -H "Authorization: Basic $(printf '%s:%s' "$SCENARIO_SDK_API_KEY" "$SCENARIO_SDK_API_SECRET" | base64 | tr -d '\n')"
# One delivery, with its payload and attemptscurl "https://api.cloud.scenario.com/v1/projects/{projectId}/webhook-endpoints/{webhookEndpointId}/events/{eventId}" \ -H "Authorization: Basic $(printf '%s:%s' "$SCENARIO_SDK_API_KEY" "$SCENARIO_SDK_API_SECRET" | base64 | tr -d '\n')"The retrieve call returns:
{ "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. |