Guides
Webhooks
Get notified when jobs finish and assets are ready, verify every delivery, and reconcile after downtime.
Webhooks tell your server when something happens, so nothing has to sit waiting on a connection. The event most integrations need is job.succeeded. It fires once a job's result exists, and it's the only completion event, so your code never handles the same completion under two different names.
Create an endpoint
An endpoint is an https URL on a public address that accepts POST requests. Create one on the Webhooks page in the console, or with the API:
const endpoint = await story0.webhookEndpoints.create({
url: "https://hooks.example.com/story0",
events: ["job.succeeded", "job.failed", "job.cancelled"],
description: "Production pipeline",
});
// Store endpoint.secret in your secret manager now: it is only returned here.endpoint = client.webhook_endpoints.create(
"https://hooks.example.com/story0",
events=["job.succeeded", "job.failed", "job.cancelled"],
description="Production pipeline",
)
# Store endpoint["secret"] in your secret manager now: it is only returned here.curl https://api.story0.com/v1/webhook_endpoints \
-H "Authorization: Bearer $STORY0_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/story0",
"events": ["job.succeeded", "job.failed", "job.cancelled"],
"description": "Production pipeline"
}'The response is 201 Created with the endpoint and its signing secret:
{
"id": "whe_01m6gvmve09a4t7c2k6r1x8m3q",
"object": "webhook_endpoint",
"url": "https://hooks.example.com/story0",
"events": ["job.succeeded", "job.failed", "job.cancelled"],
"description": "Production pipeline",
"secret": "whsec_…",
"status": "enabled",
"created_at": "2026-11-03T14:05:12.000Z"
}- The secret is shown once. It starts with
whsec_. Store it like a password; you need it to verify deliveries, and no other response returns it. eventsdefaults tojob.succeeded,job.failedandjob.cancelled. Use"*"to receive every type, including asset events and pings.- The URL must use
httpsand resolve to a public address. Private, loopback and link-local addresses are refused. - The limit is 10 enabled endpoints per account.
descriptiontakes up to 200 characters.
An endpoint receives events caused by both your test and your live keys. See "Test and live events" below.
Event types
| Type | When it fires | data |
|---|---|---|
job.succeeded |
A job finished and its result exists. | { "job": Job }, with job.result pointing at the result |
job.failed |
A job finished without a result. | { "job": Job }, with job.error saying why |
job.cancelled |
A job was cancelled before it finished. | { "job": Job } |
asset.ready |
An asset's media facts were read. | { "asset": Asset } |
asset.failed |
An asset's media can't be used. | { "asset": Asset }, with asset.error saying why |
ping |
You sent a test event. | { "endpoint_id": "whe_…", "message": "Hello from Story0" } |
Asset events fire for uploads, for POST /v1/assets, and for URLs passed straight to a processing request.
The payload
Each delivery is a POST with a JSON body:
{
"type": "job.succeeded",
"timestamp": "2026-11-03T14:21:09.402Z",
"data": {
"job": {
"id": "job_01m6gwhj05rk4v9c2x7m3d8f6p",
"object": "job",
"operation": "segment",
"status": "succeeded",
"livemode": true,
"request_id": "req_01m6gwhj03t8wq2c5n7z3k9r4a",
"asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e",
"stage": null,
"progress": 1,
"result": {
"id": "seg_01m6gwj2c15y8n3q7w2e9t4h6b",
"object": "segmentation",
"url": "/v1/segments/seg_01m6gwj2c15y8n3q7w2e9t4h6b"
},
"error": null,
"charge": { "currency": "USD", "estimate": "0.05", "max_charge": "0.50", "total": "0.05" },
"created_at": "2026-11-03T14:20:52.611Z",
"started_at": "2026-11-03T14:20:53.140Z",
"finished_at": "2026-11-03T14:21:09.377Z",
"metadata": { "story_id": "park-0412" }
}
}
}typeis the event type.timestampis when the event happened.dataholds the job or asset as it was at that moment, including themetadatayou sent with the request.
The event's ID isn't in the body. It's in the webhook-id header, and it matches the event's id in GET /v1/events. The SDK verification helpers return it as event.id.
A payload is a snapshot. Before acting on it, read the job or asset again for its current state, especially when a delivery arrives late or out of order.
Headers
| Header | Value |
|---|---|
webhook-id |
The event ID, evt_…. The same on every retry of the event. |
webhook-timestamp |
Unix time, in seconds, when this attempt was signed. It changes on every retry. |
webhook-signature |
v1, followed by a base64 HMAC-SHA256 signature. The header can hold several signatures separated by spaces. |
Content-Type |
application/json |
User-Agent |
Story0-Webhooks/1.0 |
Verify every delivery
Story0 signs deliveries using the Standard Webhooks scheme. The signature is an HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with your secret decoded from base64 (without its whsec_ prefix). Verify every delivery before trusting anything in it, and reject any whose timestamp is more than five minutes from your clock, which stops old deliveries being replayed against you.
Important: Verify the raw request body exactly as it arrived. If your framework parses the JSON first and you serialize it again, whitespace and key order change and the signature won't match.
With the SDKs
Both SDKs verify the headers, the timestamp (within 5 minutes by default) and the signature, then return the parsed event as { id, type, timestamp, data }. They raise WebhookVerificationError when anything doesn't match.
import express from "express";
import Story0, { WebhookVerificationError, type WebhookEvent } from "@story0/sdk";
const app = express();
// express.raw keeps the body as the exact bytes Story0 signed.
app.post("/story0", express.raw({ type: "application/json" }), async (req, res) => {
let event: WebhookEvent;
try {
event = await Story0.webhooks.verify(req.body, req.headers, process.env.STORY0_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.status(400).send(err.code);
throw err;
}
await enqueue(event); // your own queue: do the real work outside the request
res.sendStatus(204);
});// app/story0/route.ts
import Story0, { type WebhookEvent } from "@story0/sdk";
export async function POST(request: Request) {
const body = await request.text(); // the raw body, unparsed
let event: WebhookEvent;
try {
event = await Story0.webhooks.verify(body, request.headers, process.env.STORY0_WEBHOOK_SECRET!);
} catch {
return new Response("invalid signature", { status: 400 });
}
await enqueue(event); // your own queue
return new Response(null, { status: 204 });
}import os
import story0
from fastapi import FastAPI, Request, Response
app = FastAPI()
SECRET = os.environ["STORY0_WEBHOOK_SECRET"]
@app.post("/story0")
async def story0_webhook(request: Request) -> Response:
payload = await request.body() # the raw bytes, unparsed
try:
event = story0.verify_webhook(payload, request.headers, SECRET)
except story0.WebhookVerificationError as err:
return Response(err.code, status_code=400)
enqueue(event) # your own queue
return Response(status_code=204)import os
import story0
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["STORY0_WEBHOOK_SECRET"]
@app.post("/story0")
def story0_webhook():
try:
event = story0.verify_webhook(request.get_data(), request.headers, SECRET)
except story0.WebhookVerificationError as err:
return err.code, 400
enqueue(event) # your own queue
return "", 204The TypeScript helper accepts the body as a string or Uint8Array and headers as a Headers object or a plain object. The Python helper accepts str or bytes and any header mapping, including the objects Starlette, Werkzeug and Django provide. To change the five-minute window, pass { toleranceSeconds } in TypeScript or tolerance= in Python.
By hand
If you'd rather not use an SDK, the check takes a few lines:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyStory0(rawBody: string, headers: Record<string, string | undefined>, secret: string): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // older or newer than 5 minutes
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"));
return signatures.split(" ").some((entry) => {
const [version, signature] = entry.split(",");
const candidate = Buffer.from(signature ?? "");
return version === "v1" && candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}import base64
import hashlib
import hmac
import time
def verify_story0(raw_body: bytes, headers, secret: str) -> bool:
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
return False
if abs(time.time() - int(timestamp)) > 300: # older or newer than 5 minutes
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseCompare signatures in constant time, as these examples do, and accept a delivery if any v1 signature in the header matches.
Respond quickly
Answer with any 2xx status within 15 seconds; Story0 ignores the response body. Do the real work after you've responded, for example by putting the event on a queue, so slow processing doesn't turn into timeouts and retries. Redirects aren't followed: a 3xx answer counts as a failed attempt.
Retries
An attempt fails when your endpoint doesn't answer with a 2xx within 15 seconds, or the connection fails. Story0 then retries with exponential backoff, starting about 10 seconds after the first failure and doubling up to two hours between attempts, for up to 18 attempts in total, so the last attempt comes about 17 hours after the first. Every attempt carries the same webhook-id and body, with a fresh webhook-timestamp and signature. After the last attempt the delivery is marked failed, and the event stays available from GET /v1/events.
Important: If your endpoint answers
410 Gone, Story0 disables the endpoint at once and stops delivering to it, without retrying. Use410only when you mean to retire the endpoint: to receive events again you'll need a new endpoint, with a new secret.
Duplicates and ordering
The same event can arrive more than once, for example when your 2xx was lost on the way back, and events can arrive out of order: a job.succeeded can overtake an asset.ready. Signatures prove a delivery came from Story0; they don't make delivery exactly once. So:
- Deduplicate on
webhook-id. Record each event ID you process, for example under a unique key in your database, and skip IDs you've seen. - Treat the payload as a notification. Read the job or asset again for its current state before acting on it.
- Make the work idempotent. Handling
job.succeededtwice must not create two records. Reading a job or a result again never runs or bills anything.
async function handle(event: WebhookEvent) {
if (!(await markProcessed(event.id))) return; // false when the ID was already recorded
if (event.type !== "job.succeeded" || !event.data.job.livemode) return;
const job = await story0.jobs.retrieve(event.data.job.id);
if (job.result?.object === "segmentation") {
const result = await story0.segments.retrieve(job.result.id);
await saveTimeline(job.metadata.story_id, result.segments);
}
}def handle(event):
if not mark_processed(event["id"]): # False when the ID was already recorded
return
if event["type"] != "job.succeeded" or not event["data"]["job"]["livemode"]:
return
job = client.jobs.retrieve(event["data"]["job"]["id"])
result_ref = job["result"]
if result_ref and result_ref["object"] == "segmentation":
result = client.segments.retrieve(result_ref["id"])
save_timeline(job["metadata"].get("story_id"), result["segments"])markProcessed, mark_processed, saveTimeline and save_timeline stand in for your own code.
Test and live events
Endpoints receive events from both test and live keys. Every job and asset in data carries livemode, so check data.job.livemode (or data.asset.livemode) and ignore test events where they don't belong, as the handler above does.
Send a test event
await story0.webhookEndpoints.test("whe_01m6gvmve09a4t7c2k6r1x8m3q");client.webhook_endpoints.test("whe_01m6gvmve09a4t7c2k6r1x8m3q")curl -X POST https://api.story0.com/v1/webhook_endpoints/whe_01m6gvmve09a4t7c2k6r1x8m3q/test \
-H "Authorization: Bearer $STORY0_API_KEY"The response is 202 Accepted with the queued event:
{
"id": "evt_01m6gwr4d8k2n6q9t3v7x1z5c8",
"object": "event",
"type": "ping",
"data": { "endpoint_id": "whe_01m6gvmve09a4t7c2k6r1x8m3q", "message": "Hello from Story0" },
"created_at": "2026-11-03T14:31:02.519Z"
}The ping goes only to this endpoint, whether or not it subscribes to ping, and it's signed and retried like any other delivery. A disabled endpoint can't be tested.
Inspect deliveries
GET /v1/webhook_endpoints/{id}/deliveries lists the 50 most recent deliveries to an endpoint:
{
"object": "list",
"data": [
{
"id": "msg_01m6gwj2cv3p7h2w9k5t1n8q4c",
"object": "webhook_delivery",
"event_id": "evt_01m6gwj2ct8f3k6d1r9v4m2z7x",
"event_type": "job.succeeded",
"status": "delivered",
"attempts": 1,
"last_status": 200,
"last_error": null,
"created_at": "2026-11-03T14:21:09.402Z",
"delivered_at": "2026-11-03T14:21:09.911Z"
}
],
"has_more": false,
"next_cursor": null
}status is pending while attempts remain, delivered after a 2xx, and failed after the last attempt or a 410. last_status is the HTTP status your endpoint last returned, and last_error describes a connection failure or timeout. The SDK methods are webhookEndpoints.deliveries(id) and webhook_endpoints.deliveries(id).
Reconcile with the event list
Story0 records every event, whether or not an endpoint subscribed to it. After downtime, or as a periodic safety net, page through GET /v1/events (newest first, optionally filtered by type) and process anything you haven't seen, deduplicating on id exactly as you do for deliveries:
let cursor: string | undefined;
do {
const page = await story0.events.list({ type: "job.succeeded", limit: 100, cursor });
for (const event of page.data) {
if (await alreadyProcessed(event.id)) continue;
await handleJobSucceeded(event.data.job as Job);
}
cursor = page.next_cursor ?? undefined;
} while (cursor);cursor = None
while True:
page = client.events.list(type="job.succeeded", limit=100, cursor=cursor)
for event in page["data"]:
if already_processed(event["id"]):
continue
handle_job_succeeded(event["data"]["job"])
if not page["has_more"]:
break
cursor = page["next_cursor"]curl "https://api.story0.com/v1/events?type=job.succeeded&limit=100" \
-H "Authorization: Bearer $STORY0_API_KEY"Each event in the list has id, type, data and created_at, and the same data its deliveries carried.
Manage endpoints
GET /v1/webhook_endpointslists every endpoint, enabled and disabled, without secrets.DELETE /v1/webhook_endpoints/{id}disables an endpoint. It stops receiving deliveries and showsstatus: "disabled". A disabled endpoint can't be enabled again; create a new one instead.- To rotate a secret, create a second endpoint for the same URL, let your handler accept either secret, then disable the old endpoint. While both are enabled, each event arrives twice with the same
webhook-id, so your deduplication handles the overlap.