Story0.API

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:

json
{
  "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.
  • events defaults to job.succeeded, job.failed and job.cancelled. Use "*" to receive every type, including asset events and pings.
  • The URL must use https and resolve to a public address. Private, loopback and link-local addresses are refused.
  • The limit is 10 enabled endpoints per account. description takes 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:

json
{
  "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" }
    }
  }
}
  • type is the event type.
  • timestamp is when the event happened.
  • data holds the job or asset as it was at that moment, including the metadata you 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 "", 204

The 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 False

Compare 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. Use 410 only 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:

  1. 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.
  2. Treat the payload as a notification. Read the job or asset again for its current state before acting on it.
  3. Make the work idempotent. Handling job.succeeded twice 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:

json
{
  "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:

json
{
  "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_endpoints lists every endpoint, enabled and disabled, without secrets.
  • DELETE /v1/webhook_endpoints/{id} disables an endpoint. It stops receiving deliveries and shows status: "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.