# Story0 API > Video decisions with timecodes and evidence: upload a video, ask for an outcome (segment or screen), get typed JSON results. Base URL https://api.story0.com. OpenAPI: https://dev.story0.com/openapi.json # Overview > Send a video, describe the outcome you need, and get typed results with timecodes, evidence and review flags. Story0 is a video API for media teams: newsrooms, aggregators, archives and anyone else with more footage than people to watch it. You send a video, describe the outcome you need, and get back structured data you can build on: time intervals in seconds, labels you defined, the evidence behind each one, and a flag on every moment a person should check. ## What you can ask for There are two processing operations today. | Operation | Endpoint | What you get back | | --- | --- | --- | | Segmentation, `partition` mode | `POST /v1/segments` | A complete timeline. Every moment of the video, or of the range you ask about, carries exactly one of your labels, or `uncertain` where the footage doesn't show enough to decide. | | Segmentation, `extract` mode | `POST /v1/segments` | Only the moments that match an instruction, such as "moments where a person is jogging". No match is an empty list, not an error. | | Screening | `POST /v1/screenings` | A quick answer for each of up to 20 categories: `match`, `no_match` or `uncertain`, with the frames that support each match. | A common pattern is to screen incoming clips to route them, then segment the ones worth a closer look. Analysis is visual: Story0 inspects frames sampled from the video and doesn't listen to the audio track. ## How the pieces fit together The API has three core objects. - **Assets** hold your media. You create one by uploading a file or by giving Story0 a direct link to one. Story0 reads its media facts (duration, frame size, frame rate, codec, audio, size and SHA-256 hash) and keeps the media for 7 days. - **Jobs** are requested operations on an asset. A segmentation or screening request returns its job straight away. The job moves from `queued` to `running` and ends as `succeeded`, `failed` or `cancelled`, reporting its stage, progress and charge along the way. - **Results** are what a succeeded job produced: a `segmentation` or a `screening`. Results are typed and immutable, and they're kept for 30 days. The job points to its result, and the result points back to its job and asset. Processing is asynchronous by design, because analyzing video takes longer than an HTTP request should stay open. There is exactly one completion event, `job.succeeded`, and it fires only after the result exists, so whoever receives it can always fetch the result. You can wait for a job in three ways: - long-poll `GET /v1/jobs/{id}?wait=20`, which answers the moment the job finishes; - call an SDK helper such as `segments.createAndWait` in TypeScript or `segments.create_and_wait` in Python; or - receive the `job.succeeded` [webhook](/docs/webhooks). ## Three steps 1. **Bring the media.** Upload a file or create an asset from a URL. For one-off requests you can skip this step and pass `input.url` straight to a processing request. 2. **Ask for an outcome.** Send `POST /v1/segments` or `POST /v1/screenings` with an instruction, your labels or categories, a `billing.max_charge` that caps what the request may cost, and an `Idempotency-Key` so a retry never runs or bills the work twice. 3. **Collect the result.** When the job succeeds, fetch the typed result from `job.result.url` and use its intervals, evidence and review flags in your own tools. Here are all three steps in one call, run against a sample clip hosted for these docs: ```ts tab="TypeScript" import Story0 from "@story0/sdk"; const story0 = new Story0(); // reads STORY0_API_KEY const result = await story0.segments.createAndWait({ input: { url: "https://dev.story0.com/demo/park-dog-demo.mp4" }, mode: "extract", instruction: "Moments where a person is jogging", billing: { max_charge: { currency: "USD", amount: "0.25" } }, }); for (const moment of result.segments) { console.log(moment.start, moment.end, moment.title); } ``` ```python tab="Python" from story0 import Story0 client = Story0() # reads STORY0_API_KEY result = client.segments.create_and_wait( input={"url": "https://dev.story0.com/demo/park-dog-demo.mp4"}, mode="extract", instruction="Moments where a person is jogging", billing={"max_charge": {"currency": "USD", "amount": "0.25"}}, ) for moment in result["segments"]: print(moment["start"], moment["end"], moment.get("title")) ``` ```bash tab="cURL" curl https://api.story0.com/v1/segments \ -H "Authorization: Bearer $STORY0_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: overview-jogging-1" \ -d '{ "input": { "url": "https://dev.story0.com/demo/park-dog-demo.mp4" }, "mode": "extract", "instruction": "Moments where a person is jogging", "billing": { "max_charge": { "currency": "USD", "amount": "0.25" } } }' ``` The SDK helpers wait for the job and return the result. The cURL call returns the job with `202 Accepted`; [Jobs and results](/docs/jobs) shows how to wait for it and fetch the result. ## Live and test keys | | Live key | Test key | | --- | --- | --- | | Prefix | `s0_live_` | `s0_test_` | | Results | Analysis of your footage | Deterministic labeled fixtures, marked `fixture: true` | | Billing | Charged in credits | Never billed | Both kinds of key use the same base URL and endpoints; the key alone decides the mode. Build and test with a test key, then switch the key. See [Test mode](/docs/test-mode). ## Conventions - **Base URL:** `https://api.story0.com`. Every endpoint is under `/v1`. - **Authentication:** send `Authorization: Bearer ` with a key from the [console](https://dev.story0.com/console). Keys belong on servers only. - **Version:** the current API version is `2026-11-01`. Every response carries a `Story0-Version: 2026-11-01` header, and the SDKs send the same header on every request. Within a version, changes are additive: new fields can appear, so ignore fields you don't recognize. - **JSON:** request bodies are JSON, sent with `Content-Type: application/json`. - **Time:** seconds from the start of the video, as numbers with up to three decimal places. Results say so with `time_unit: "seconds"`. - **Money:** decimal strings with a currency, such as `{ "currency": "USD", "amount": "0.50" }`. Only USD is supported. - **IDs:** prefixed strings such as `asset_…`, `upl_…`, `job_…`, `seg_…`, `scr_…`, `whe_…` and `evt_…`. Treat them as opaque. - **Lists:** newest first, with `limit`, `cursor`, `has_more` and `next_cursor`. - **Errors:** RFC 9457 problem documents with a stable `code`. See [Errors](/docs/errors). - **Request IDs:** every response has an `x-request-id` header. Log it next to your own request logs. ## Results are made to be checked Every result says where it's unsure. Partition timelines mark moments without enough evidence as `uncertain`; screenings answer `uncertain` instead of guessing; every interval and category carries a `review` object with `required` and `reasons`; and evidence points at the frames behind a label so a reviewer can jump straight to them. Screening scores are uncalibrated estimates, not probabilities. Use results to route, prioritize and prepare work for people. Editorial, licensing and safety decisions stay with your team, and `review.required: false` is not a clearance for any of them. ## Where to go next - [Quickstart](/docs/quickstart): from a new key to a drawn timeline in about five minutes. - [Uploading media](/docs/uploads): resumable uploads, URL inputs, media facts and retention. - [Segmentation](/docs/segments): `partition` and `extract`, sampling, ranges, constraints and writing labels. - [Screening](/docs/screenings): fast triage across up to 20 categories. - [Webhooks](/docs/webhooks): events, signatures, retries and reconciliation. - [Jobs and results](/docs/jobs): the lifecycle, waiting, cancelling and recovering after a disconnect. - [Idempotency and retries](/docs/idempotency): retrying without running or paying for work twice. - [Pricing and billing](/docs/billing): prices, credits, estimates, caps and receipts. - [Test mode](/docs/test-mode): free, deterministic fixtures for development and CI. - [API reference](/docs/api): every endpoint and schema. - [Errors](/docs/errors): every error code and what to do about it. - [Limits and retention](/docs/limits): sizes, rates and how long things are kept. - [SDKs](/docs/sdks): the TypeScript and Python libraries. The machine-readable contract is the OpenAPI 3.1 document at `https://api.story0.com/v1/openapi.json`. The TypeScript SDK's types are generated from it. --- # Quickstart > Go from a new API key to a drawn, clickable timeline of a video in about five minutes. In this quickstart you upload a short video, ask Story0 to label every moment by whether a dog is visible, print the intervals and draw them as a timeline that seeks a video player. It takes about five minutes. With a live key, a video of 30 seconds or less costs $0.05. ## Before you start You need: - **A Story0 account.** Sign in at [dev.story0.com/console](https://dev.story0.com/console) and verify your email address. New accounts then receive $10 of free credit (1,000 credits). - **Node.js 18 or later, or Python 3.9 or later.** - **A short video you own or have the rights to process.** If you don't have one handy, use the sample made for these docs: a 24-second clip in which a dog appears in a park and, later, a jogger passes. Download it with `curl -O https://dev.story0.com/demo/park-dog-demo.mp4`, or skip the download and process it straight from its URL (see "Try it without a file" below). ## 1. Create an API key Open **Keys** in the console ([dev.story0.com/console/keys](https://dev.story0.com/console/keys)) and create a live key. Copy it when it appears: Story0 stores only a hash, so the full key is shown once. Keep the key in an environment variable. Both SDKs read `STORY0_API_KEY` automatically. ```bash export STORY0_API_KEY="s0_live_..." ``` > **Important:** API keys belong on servers and in scripts. Never put one in browser code or a mobile app, and never commit one to source control. > **Tip:** A test key (`s0_test_…`) runs exactly the same code for free, but returns labeled fixtures instead of analyzing your video. Use a live key here so you see a real result. See [Test mode](/docs/test-mode). ## 2. Install an SDK ```bash tab="TypeScript" npm install https://dev.story0.com/sdk/story0-sdk-0.1.0.tgz ``` ```bash tab="Python" pip install https://dev.story0.com/sdk/story0-0.1.0-py3-none-any.whl ``` The TypeScript package is `@story0/sdk` and the Python package is `story0`. The TypeScript examples on this page use top-level `await` and are also valid JavaScript, so you can save them as `quickstart.mjs` and run `node quickstart.mjs`. ## 3. Upload the video ```ts tab="TypeScript" import Story0 from "@story0/sdk"; const story0 = new Story0(); // reads STORY0_API_KEY const asset = await story0.assets.uploadAndWait({ file: "./park-dog-demo.mp4" }); console.log(`${asset.id} is ${asset.status}: ${asset.media?.duration_seconds} s`); ``` ```python tab="Python" from story0 import Story0 client = Story0() # reads STORY0_API_KEY asset = client.assets.upload_and_wait(file="park-dog-demo.mp4") print(f"{asset['id']} is {asset['status']}: {asset['media']['duration_seconds']} s") ``` `uploadAndWait` opens a resumable upload session, sends the file in chunks straight to storage, completes the upload, and then waits until the asset is `ready`, which means Story0 has read its media facts. That usually takes a few seconds. The Python SDK returns plain dictionaries, so you read fields with `asset["id"]`. ## 4. Ask for a timeline Now request a `partition`: a timeline in which every moment gets exactly one label. ```ts tab="TypeScript" const result = await story0.segments.createAndWait( { input: { asset_id: asset.id }, mode: "partition", instruction: "Partition the entire video by whether a dog is visibly present. " + "An audible bark alone does not count.", labels: [ { key: "dog_visible", description: "At least one dog is visible." }, { key: "no_dog_visible", description: "No dog is visible." }, ], uncertainty: { label: "uncertain" }, billing: { max_charge: { currency: "USD", amount: "0.50" } }, }, { idempotencyKey: `quickstart-${asset.id}` }, ); ``` ```python tab="Python" result = client.segments.create_and_wait( input={"asset_id": asset["id"]}, mode="partition", instruction=( "Partition the entire video by whether a dog is visibly present. " "An audible bark alone does not count." ), labels=[ {"key": "dog_visible", "description": "At least one dog is visible."}, {"key": "no_dog_visible", "description": "No dog is visible."}, ], uncertainty={"label": "uncertain"}, billing={"max_charge": {"currency": "USD", "amount": "0.50"}}, idempotency_key=f"quickstart-{asset['id']}", ) ``` What each part does: - `mode: "partition"` asks for complete coverage: the intervals tile the whole video with no gaps or overlaps. - `instruction` describes the task in plain language, including what doesn't count. - `labels` are the mutually exclusive states, between 2 and 12 of them. Keys are lowercase identifiers that appear in the result; descriptions tell Story0 what each one means. - `uncertainty.label` names the label Story0 uses where the frames don't show enough to decide. `uncertain` is the default; it's spelled out here so you can see it. - `billing.max_charge` caps what this request may cost. If the work would cost more, Story0 refuses it before starting and charges nothing. - The idempotency key makes the request safe to retry. If the response is lost and the request is sent again with the same key, Story0 returns the same job instead of starting, and billing, a second one. This key is tied to the asset, so each new upload gets a new key. `createAndWait` submits the request, waits for the job by long-polling in short holds, and then fetches the segmentation. If the job fails, it raises `JobFailedError`. If your wait limit (10 minutes by default) passes first, it raises `WaitTimeoutError`, which carries the job ID so you can resume waiting. See [Jobs and results](/docs/jobs). ## 5. Print the intervals ```ts tab="TypeScript" for (const s of result.segments) { const flag = s.review.required ? " <- review" : ""; console.log(`${s.start.toFixed(2).padStart(6)} – ${s.end.toFixed(2).padStart(6)} ${s.label}${flag}`); } console.log(`Charged $${result.receipt.total} for ${result.receipt.billable_seconds} billable seconds`); ``` ```python tab="Python" for s in result["segments"]: flag = " <- review" if s["review"]["required"] else "" print(f"{s['start']:6.2f} – {s['end']:6.2f} {s['label']}{flag}") print(f"Charged ${result['receipt']['total']} for {result['receipt']['billable_seconds']} billable seconds") ``` For the sample clip, the output looks like this: ```bash $ node quickstart.mjs asset_01m6gwgx34hq7d2m9ycv5k8b1e is ready: 24 s 0.00 – 4.00 no_dog_visible 4.00 – 15.00 dog_visible 15.00 – 24.00 no_dog_visible Charged $0.05 for 30 billable seconds ``` The intervals cover 0 to 24 seconds, and each one starts exactly where the previous one ends. The sample is clear enough that no moment came back uncertain. On harder footage, where the subject is obscured, blurred or out of frame, you'll also see intervals labeled `uncertain`, marked `<- review` here because they carry `review.required: true`: Story0 flags them instead of guessing. The receipt shows 30 billable seconds because every video bills at least 30 seconds. ## 6. Draw the timeline Partition intervals tile the video exactly, so drawing them takes two numbers per interval: - left edge, in percent: `100 × start / duration_seconds` - width, in percent: `100 × (end − start) / duration_seconds` This writes a `timeline.html` page with the video and one bar per interval. Clicking a bar seeks the player to the start of that interval. ```ts tab="TypeScript" import { writeFileSync } from "node:fs"; const colors = new Map([ ["dog_visible", "#16803C"], ["no_dog_visible", "#64748B"], ["uncertain", "#B45309"], ]); const bars = result.segments.map((s) => { const left = (100 * s.start) / result.duration_seconds; const width = (100 * (s.end - s.start)) / result.duration_seconds; return ``; }); writeFileSync( "timeline.html", `
${bars.join("")}
`, ); ``` ```python tab="Python" colors = {"dog_visible": "#16803C", "no_dog_visible": "#64748B", "uncertain": "#B45309"} duration = result["duration_seconds"] bars = [] for s in result["segments"]: left = 100 * s["start"] / duration width = 100 * (s["end"] - s["start"]) / duration bars.append( f'' ) with open("timeline.html", "w", encoding="utf-8") as f: f.write( '\n' f'
{"".join(bars)}
' ) ``` Open `timeline.html` in a browser from the folder that holds the video. Because the intervals are ordered and contiguous, there are no gaps to fill and no overlaps to resolve. If you analyze only part of a video with a `range`, measure from `range.start` and divide by `range.end − range.start` instead. ## Do the same with cURL The same flow over plain HTTP, using `jq` to read fields from the responses: ```bash API=https://api.story0.com AUTH="Authorization: Bearer $STORY0_API_KEY" # 1. Open an upload session UPLOAD=$(curl -s "$API/v1/uploads" -H "$AUTH" -H "Content-Type: application/json" \ -d '{"filename": "park-dog-demo.mp4", "content_type": "video/mp4"}') UPLOAD_ID=$(echo "$UPLOAD" | jq -r .id) UPLOAD_URL=$(echo "$UPLOAD" | jq -r .upload_url) # 2. Send the bytes to the session URL. No API key: the URL is pre-authorized. curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" --upload-file park-dog-demo.mp4 # 3. Complete the upload to create the asset ASSET_ID=$(curl -s -X POST "$API/v1/uploads/$UPLOAD_ID/complete" -H "$AUTH" | jq -r .id) # 4. Wait until the asset is ready while :; do STATUS=$(curl -s "$API/v1/assets/$ASSET_ID" -H "$AUTH" | jq -r .status) [ "$STATUS" = "processing" ] || break sleep 1 done echo "asset $ASSET_ID is $STATUS" # 5. Ask for the partition JOB_ID=$(curl -s "$API/v1/segments" -H "$AUTH" -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-$ASSET_ID" \ -d '{ "input": { "asset_id": "'"$ASSET_ID"'" }, "mode": "partition", "instruction": "Partition the entire video by whether a dog is visibly present. An audible bark alone does not count.", "labels": [ { "key": "dog_visible", "description": "At least one dog is visible." }, { "key": "no_dog_visible", "description": "No dog is visible." } ], "uncertainty": { "label": "uncertain" }, "billing": { "max_charge": { "currency": "USD", "amount": "0.50" } } }' | jq -r .id) # 6. Long-poll until the job finishes. Each call waits up to 20 seconds. while :; do JOB=$(curl -s "$API/v1/jobs/$JOB_ID?wait=20" -H "$AUTH") STATUS=$(echo "$JOB" | jq -r .status) [ "$STATUS" = "queued" ] || [ "$STATUS" = "running" ] || break done [ "$STATUS" = "succeeded" ] || { echo "$JOB" | jq .error; exit 1; } # 7. Fetch the result curl -s "$API$(echo "$JOB" | jq -r .result.url)" -H "$AUTH" | jq . ``` Step 5 answers `202 Accepted` with the job, a `Location` header that points at it, and a hint for when to check back: ```http HTTP/1.1 202 Accepted Content-Type: application/json Location: /v1/jobs/job_01m6gwhj05rk4v9c2x7m3d8f6p Retry-After: 2 Story0-Version: 2026-11-01 x-request-id: req_01m6gwhj03t8wq2c5n7z3k9r4a ``` The body is the job, still `queued`: ```json { "id": "job_01m6gwhj05rk4v9c2x7m3d8f6p", "object": "job", "operation": "segment", "status": "queued", "livemode": true, "request_id": "req_01m6gwhj03t8wq2c5n7z3k9r4a", "asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e", "stage": null, "progress": null, "result": null, "error": null, "charge": { "currency": "USD", "estimate": "0.05", "max_charge": "0.50", "total": null }, "created_at": "2026-11-03T14:20:52.611Z", "started_at": null, "finished_at": null, "metadata": {} } ``` When the job succeeds, `result.url` points at the segmentation and `charge.total` shows what was charged. ## The result This is the segmentation for the sample clip: ```json { "id": "seg_01m6gwj2c15y8n3q7w2e9t4h6b", "object": "segmentation", "job_id": "job_01m6gwhj05rk4v9c2x7m3d8f6p", "asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e", "livemode": true, "mode": "partition", "engine": "segment-2026-11", "time_unit": "seconds", "duration_seconds": 24, "range": { "start": 0, "end": 24 }, "coverage": { "timeline": "complete", "sampled_frames": 48, "frame_interval_seconds": 0.5, "uncertain_seconds": 0, "modalities": ["visual"] }, "boundary_resolution_seconds": 0.5, "segments": [ { "id": "interval_1", "start": 0, "end": 4, "label": "no_dog_visible", "review": { "required": false, "reasons": [] }, "evidence": [ { "id": "ev_1", "kind": "seen", "start": 0.25, "end": 0.5, "description": "The park path has no dogs present." } ] }, { "id": "interval_2", "start": 4, "end": 15, "label": "dog_visible", "review": { "required": false, "reasons": [] }, "evidence": [ { "id": "ev_2", "kind": "seen", "start": 4.25, "end": 4.5, "description": "Golden retriever walking on a path in the park." }, { "id": "ev_3", "kind": "seen", "start": 8.25, "end": 8.5, "description": "Golden retriever walking on a park path." } ] }, { "id": "interval_3", "start": 15, "end": 24, "label": "no_dog_visible", "review": { "required": false, "reasons": [] }, "evidence": [ { "id": "ev_4", "kind": "seen", "start": 15.25, "end": 15.5, "description": "Person walking on path, no dog is present." }, { "id": "ev_5", "kind": "seen", "start": 20.25, "end": 20.5, "description": "Empty park path with trees and benches, no dog." } ] } ], "config": { "mode": "partition", "instruction": "Partition the entire video by whether a dog is visibly present. An audible bark alone does not count.", "labels": [ { "key": "dog_visible", "description": "At least one dog is visible." }, { "key": "no_dog_visible", "description": "No dog is visible." } ], "uncertainty_label": "uncertain", "snap": "none", "fps": null, "range": { "start": null, "end": null }, "constraints": { "min_seconds": null, "max_seconds": null, "max_segments": null }, "engine": "segment-2026-11" }, "provenance": { "engine": "segment-2026-11", "analysis_reused": false, "api_version": "2026-11-01" }, "receipt": { "currency": "USD", "total": "0.05", "items": [ { "item": "analysis", "amount": "0.04", "unit_price_per_minute": "0.08" }, { "item": "segment", "amount": "0.01", "unit_price_per_minute": "0.02" } ], "billable_seconds": 30, "price_version": "2026-11-01" }, "retention": { "result_expires_at": "2026-12-03T14:21:09.377Z", "media_expires_at": "2026-11-10T14:20:31.204Z" }, "created_at": "2026-11-03T14:21:09.377Z" } ``` The fields you'll use most: - **`segments`** are the intervals, in time order. `start` is inclusive and `end` is exclusive, both in seconds from the start of the video. - **`review`** marks intervals a person should check (`required: true`) and says why in `reasons`. Nothing here needs review; an `uncertain` interval would. - **`evidence`** lists sampled frames that support a label, with a short description and a time to seek to. Evidence supports the label at that moment; it isn't proof that the label holds for every frame of the interval. - **`coverage`** says how Story0 looked. `timeline: "complete"` means the intervals cover the whole range; `sampled_frames` and `frame_interval_seconds` say how densely it sampled (two frames per second here); `uncertain_seconds` totals the time it couldn't decide. - **`boundary_resolution_seconds`** is the grid boundaries fall on. The boundary at 4 means "somewhere between the frames at 3.75 and 4.25", not a millisecond-exact cut. - **`config`** is your request as Story0 resolved it, defaults included. - **`receipt`** and **`retention`** say what the result cost and when the result and the media expire. [Segmentation](/docs/segments) describes every field and guarantee in detail. ## Try it without a file Pass the sample's URL as the input, and Story0 fetches it inside the job: ```ts tab="TypeScript" const result = await story0.segments.createAndWait({ input: { url: "https://dev.story0.com/demo/park-dog-demo.mp4" }, mode: "partition", instruction: "Partition the entire video by whether a dog is visibly present. An audible bark alone does not count.", labels: [ { key: "dog_visible", description: "At least one dog is visible." }, { key: "no_dog_visible", description: "No dog is visible." }, ], billing: { max_charge: { currency: "USD", amount: "0.50" } }, }); ``` ```python tab="Python" result = client.segments.create_and_wait( input={"url": "https://dev.story0.com/demo/park-dog-demo.mp4"}, mode="partition", instruction="Partition the entire video by whether a dog is visibly present. An audible bark alone does not count.", labels=[ {"key": "dog_visible", "description": "At least one dog is visible."}, {"key": "no_dog_visible", "description": "No dog is visible."}, ], billing={"max_charge": {"currency": "USD", "amount": "0.50"}}, ) ``` With a URL, Story0 doesn't know the video's duration when it accepts the job, so it holds the whole `max_charge` ($0.50 here) and releases whatever the job doesn't use when it finishes. The fetched video becomes an asset like any other: the job's `asset_id` identifies it, so follow-up requests can use it without fetching the file again. ## Run it again Run your script a second time. It uploads a second copy, so the asset ID and the idempotency key are new and Story0 creates a new job. The bytes and the request are identical, though, so Story0 answers from your account's earlier analysis: the result has `provenance.analysis_reused: true`, the same segments, and a receipt with only the `segment` line, $0.01 instead of $0.05. See [Pricing and billing](/docs/billing). ## Next steps - [Segmentation](/docs/segments): `extract` mode, ranges, sampling, constraints and how to write good labels. - [Screening](/docs/screenings): quick triage across up to 20 categories for $0.02 a minute. - [Webhooks](/docs/webhooks): receive `job.succeeded` instead of waiting in your process. - [Jobs and results](/docs/jobs): the lifecycle, cancellation and recovering after a disconnect. - [Idempotency and retries](/docs/idempotency): choosing keys that survive restarts. --- # Uploading media > Turn files and links into assets with resumable uploads or URL fetches, and know exactly how long Story0 keeps them. Every processing request works on an asset: a video that Story0 holds for you, along with the media facts it read from the file. There are three ways to create one. | Way | Best for | How | | --- | --- | --- | | Upload a file | Files on your servers or in your pipeline | `POST /v1/uploads`, send the bytes, then `POST /v1/uploads/{id}/complete`. The SDKs wrap all three in `assets.uploadAndWait`. | | Fetch a URL | Files already hosted at a direct link | `POST /v1/assets` with a `url` | | Pass a URL to a processing request | One-off requests | `"input": { "url": … }` in `POST /v1/segments` or `POST /v1/screenings` | Uploads go straight to storage through a resumable session; the API never relays your video bytes. ## The asset object ```json { "id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e", "object": "asset", "status": "ready", "source": "upload", "filename": "park-dog-demo.mp4", "livemode": true, "media": { "duration_seconds": 24, "width": 1280, "height": 720, "fps": 30, "video_codec": "h264", "has_audio": true, "bytes": 4231419, "sha256": "64b0ade471acbe82e8254711d3ac09d814da63bcc0edec256b02936364876c53" }, "error": null, "created_at": "2026-11-03T14:20:31.204Z", "ready_at": "2026-11-03T14:20:34.918Z", "retention": { "media_expires_at": "2026-11-10T14:20:31.204Z" } } ``` | Field | Description | | --- | --- | | `id` | The asset ID, `asset_…`. Pass it as `input.asset_id`. | | `status` | `processing`, `ready`, `failed` or `deleted`. | | `source` | `upload` or `url`. | | `filename` | The name you supplied, or `null`. | | `livemode` | `true` for assets created with a live key, `false` for a test key. Each mode can only use its own assets. | | `media` | The media facts, described below. `null` until the asset is `ready`. | | `error` | `{ code, title, detail }` when the asset `failed`, otherwise `null`. | | `created_at`, `ready_at` | When the asset was created and when its media facts were read. | | `retention.media_expires_at` | When Story0 deletes the media. | ### Asset status | Status | Meaning | | --- | --- | | `processing` | Story0 is receiving the file, fetching the URL or reading the media facts. | | `ready` | The media facts are known. Processing requests can be quoted exactly. | | `failed` | The media can't be used; `error.code` says why. Processing requests that reference the asset are refused with `asset_failed`. | | `deleted` | You deleted the media. You see this status in the delete response; afterwards the asset isn't returned at all. | You don't have to wait for `ready` before you start a job. A job on a `processing` asset reads the media facts itself as its first stage. Until the duration is known, though, Story0 can't quote the job, so it holds your whole `billing.max_charge` (or $2.00 if you didn't set one) until the job finishes. See [Pricing and billing](/docs/billing). ## Upload with an SDK ```ts tab="TypeScript" const asset = await story0.assets.uploadAndWait({ file: "./interviews/raw-0412.mov", contentType: "video/quicktime", onProgress: (sent, total) => console.log(`${sent} of ${total ?? "?"} bytes`), }); ``` ```python tab="Python" asset = client.assets.upload_and_wait( file="interviews/raw-0412.mov", content_type="video/quicktime", on_progress=lambda sent, total: print(f"{sent} of {total or '?'} bytes"), ) ``` You can pass as `file`: - **TypeScript:** a file path (in Node), a `Blob` or `File`, an `ArrayBuffer` or `Uint8Array`, a `ReadableStream`, or an async iterable of `Uint8Array` chunks. - **Python:** a path (`str` or `pathlib.Path`), `bytes`, a binary file object opened with `"rb"`, or an iterable of `bytes`. The SDKs send the file in 8 MiB chunks by default (set `chunkSize` or `chunk_size`; it's rounded down to a multiple of 256 KiB) and hold no more than a chunk or two in memory. A failed chunk is retried. For a source the SDK can read again (a path, blob, buffer or seekable file), it first asks the session how much it stored and resumes from there. `upload` returns as soon as the bytes are in, with the asset still `processing`. `uploadAndWait` (`upload_and_wait` in Python) also waits until the asset is `ready`, for up to 5 minutes by default (`waitTimeoutMs` in the TypeScript request options, `wait_timeout` in Python). If the media can't be used, it raises an error whose `code` is the asset's error code, such as `media_unreadable`. The SDKs send `video/mp4` as the content type unless you set `contentType` or `content_type`. Story0 accepts any `video/*` type or `application/octet-stream`, and identifies the actual format from the file itself. ## Upload over HTTP The upload protocol has three requests: open a session, send the bytes, complete it. ### 1. Open an upload session ```bash curl https://api.story0.com/v1/uploads \ -H "Authorization: Bearer $STORY0_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "filename": "raw-0412.mov", "content_type": "video/quicktime", "bytes": 734003200 }' ``` | Field | Required | Description | | --- | --- | --- | | `filename` | No | Up to 255 characters. Shown on the asset. | | `content_type` | No | A `video/*` type or `application/octet-stream`. Default `video/mp4`. Anything else is refused with `unsupported_input`. | | `bytes` | No | The file size. If it's over 2 GB, Story0 refuses the upload now with `unsupported_input`, before you send anything. | The response is `201 Created` with the session: ```json { "id": "upl_01m6gwgx2nf3c8r7tz5qa2vy9d", "object": "upload", "status": "pending", "upload_url": "https://…", "method": "PUT", "headers": { "Content-Type": "video/quicktime" }, "chunk_size_multiple": 262144, "expires_at": "2026-11-04T14:20:31.012Z", "asset_id": null } ``` `upload_url` is a pre-authorized address for this one upload. Don't send your API key to it, and treat the URL itself like a short-lived credential. Send the headers listed in `headers` with every request to it. The session lasts 24 hours (`expires_at`); after that, open a new one. ### 2. Send the bytes For small files, send everything in one `PUT`: ```bash curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/quicktime" --upload-file raw-0412.mov ``` A `2xx` response means the file is stored. For large files or unreliable networks, send the file in chunks. Each request carries a `Content-Range` header, and every chunk except the last must be a multiple of `chunk_size_multiple` bytes (262,144, which is 256 KiB): ```http PUT {upload_url} Content-Type: video/quicktime Content-Range: bytes 0-8388607/734003200 (8,388,608 bytes of the file) ``` An intermediate chunk is answered with `308` and a `Range` header that says what's stored so far: ```http HTTP/1.1 308 Range: bytes=0-8388607 ``` `308` means "continue": send the next chunk, starting at the byte after the end of `Range`. The final chunk is answered with a `2xx`. If you don't know the total size up front, for example because you're streaming the output of another process, use `*` as the total on intermediate chunks (`Content-Range: bytes 8388608-16777215/*`) and give the real total on the last one. ### 3. Resume after an interruption If a chunk fails or your process restarts, ask the session what it has with an empty `PUT` and `Content-Range: bytes */` (or `bytes */*` if you don't know the total yet): ```bash curl -i -X PUT "$UPLOAD_URL" -H "Content-Range: bytes */734003200" -H "Content-Length: 0" ``` - `308` with `Range: bytes=0-N`: the first N + 1 bytes are stored. Continue from byte N + 1. - `308` with no `Range` header: nothing is stored yet. Start from byte 0. - `2xx`: the upload is already complete. ### 4. Complete the upload ```bash curl -X POST https://api.story0.com/v1/uploads/upl_01m6gwgx2nf3c8r7tz5qa2vy9d/complete \ -H "Authorization: Bearer $STORY0_API_KEY" ``` This creates the asset and returns it with `status: "processing"`; Story0 then reads its media facts, which usually takes a few seconds. Completing the same upload again returns the same asset, so this call is safe to retry. If the bytes haven't all arrived yet, you get `422 upload_missing`: finish sending, then complete again. ### 5. Wait until the asset is ready Poll `GET /v1/assets/{id}` until `status` is `ready` or `failed`, or subscribe to the `asset.ready` and `asset.failed` [webhooks](/docs/webhooks). ## Create an asset from a URL ```ts tab="TypeScript" const created = await story0.assets.create({ url: "https://media.example.com/feeds/2026-11-03/clip-118.mp4" }); const asset = await story0.assets.wait(created.id); ``` ```python tab="Python" created = client.assets.create("https://media.example.com/feeds/2026-11-03/clip-118.mp4") asset = client.assets.wait(created["id"]) ``` ```bash tab="cURL" curl https://api.story0.com/v1/assets \ -H "Authorization: Bearer $STORY0_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://media.example.com/feeds/2026-11-03/clip-118.mp4" }' ``` Story0 returns the asset immediately with `status: "processing"`, fetches the file in the background, and then marks the asset `ready` or `failed`. You can pass an optional `filename`. The URL must point at the video file itself: - `http` or `https` only, with no username or password in the URL. - Platform pages such as YouTube, Instagram or TikTok links aren't fetched. Get the file through the platform's own tools, where its terms allow, and upload it. - A URL that returns a web page (`text/html`) is refused. - The host must resolve to a public address. Private, loopback and link-local addresses, and names such as `localhost` or `*.local`, are refused. Redirects are followed up to 5 times, and every hop is checked the same way. - The file must be 2 GB or smaller, and the download must finish within 10 minutes. If your server is slow to serve a large file, upload it instead. If the URL or the file it returns can't be used, the asset becomes `failed` with one of these codes: | Code | Cause | | --- | --- | | `source_unavailable` | The host couldn't be resolved or reached, answered with an error status, redirected more than 5 times, or the download didn't finish in time. | | `unsupported_input` | A platform page, a web page, a private address, credentials in the URL, a file over 2 GB, or a video longer than 20 minutes. | | `media_unreadable` | The file isn't a readable video: it has no video stream or no measurable duration. | > **Note:** Without an `Idempotency-Key`, each `POST /v1/assets` call creates a new asset and fetches the URL again. Send a key (the SDKs do this for you) and a retry with the same key and body returns the asset from the first call instead. See [Idempotency and retries](/docs/idempotency). ## Pass a URL straight to a processing request For a one-off request, put the URL in `input`: ```json { "input": { "url": "https://media.example.com/feeds/2026-11-03/clip-118.mp4" }, "mode": "extract", "instruction": "Moments where a crowd is visible" } ``` Story0 creates an asset for the URL and fetches it during the job's `ingesting` stage, under the same rules as above. If the fetch fails, the job fails with the asset's error code (for example `source_unavailable`), nothing is charged, and the asset is marked `failed` too. Two things differ from passing an `asset_id`: - **The hold is the cap.** The duration isn't known when Story0 accepts the job, so it holds the whole `billing.max_charge`, or $2.00 if you didn't set one, until the job finishes. If the video turns out to cost more than the cap, the job fails with `charge_cap_exceeded` before any analysis, and nothing is charged. - **Each request fetches again.** Every request with a URL input creates a new asset. The job's `asset_id` identifies it, so use that for follow-up requests on the same video, especially if your URLs are signed links that change. Send exactly one of `input.asset_id` and `input.url`. Sending both, or neither, is `invalid_request`. ## Media facts When an asset is `ready`, `media` describes the file: | Field | Description | | --- | --- | | `duration_seconds` | Length in seconds, to the millisecond. Billing is based on it. | | `width`, `height` | Frame size in pixels. | | `fps` | Average frame rate. | | `video_codec` | Codec name, such as `h264`. | | `has_audio` | Whether the file has an audio track. Analysis is visual, so audio doesn't affect results. | | `bytes` | File size in bytes. | | `sha256` | SHA-256 hash of the file. Identical files have identical hashes, which is how Story0 recognizes a repeat request for [analysis reuse](/docs/billing). | `width`, `height`, `fps` and `video_codec` can be `null` when the file doesn't record them. ## Retention and deletion Story0 keeps an asset's media for 7 days from when the asset was created (`retention.media_expires_at`), then deletes it. If a job that needs the media is still queued or running at that moment, deletion waits until the job finishes. After deletion the asset isn't returned by `GET /v1/assets/{id}` (you get `404 not_found`) or by the list, and new requests can't use it. Results that already exist keep their own 30-day retention. To delete media sooner: ```ts tab="TypeScript" await story0.assets.delete("asset_01m6gwgx34hq7d2m9ycv5k8b1e"); ``` ```python tab="Python" client.assets.delete("asset_01m6gwgx34hq7d2m9ycv5k8b1e") ``` ```bash tab="cURL" curl -X DELETE https://api.story0.com/v1/assets/asset_01m6gwgx34hq7d2m9ycv5k8b1e \ -H "Authorization: Bearer $STORY0_API_KEY" ``` The response is the asset with `status: "deleted"`. A job that has already read the media keeps going; a job that hasn't started yet fails with `asset_deleted` and isn't charged. Deleting the same asset again returns `404 not_found`. ## List assets `GET /v1/assets` returns assets newest first, 20 per page by default (`limit` accepts 1 to 100). When `has_more` is `true`, pass `next_cursor` as `cursor` to get the next page. Deleted and expired assets aren't listed. The list includes assets from both test and live keys, so check `livemode`. ```ts tab="TypeScript" let cursor: string | undefined; do { const page = await story0.assets.list({ limit: 100, cursor }); for (const asset of page.data) console.log(asset.id, asset.status, asset.filename); cursor = page.next_cursor ?? undefined; } while (cursor); ``` ```python tab="Python" cursor = None while True: page = client.assets.list(limit=100, cursor=cursor) for asset in page["data"]: print(asset["id"], asset["status"], asset["filename"]) if not page["has_more"]: break cursor = page["next_cursor"] ``` ```bash tab="cURL" curl "https://api.story0.com/v1/assets?limit=100" -H "Authorization: Bearer $STORY0_API_KEY" ``` ## Uploading from a browser API keys never reach a browser, but the bytes can go straight from your users' browsers to Story0 storage without passing through your servers: 1. Your server calls `POST /v1/uploads` with its key and sends only the returned `upload_url` and `headers` to the page. 2. The page `PUT`s the file (or `Content-Range` chunks) to `upload_url`. Storage accepts cross-origin uploads to an upload session from any site; the session URL itself is the permission, so treat it like a short-lived secret and give it only to the user doing the upload. 3. Your server calls `POST /v1/uploads/{id}/complete`. ```ts tab="Browser" // upload_url and headers came from your server, never your API key. const res = await fetch(uploadUrl, { method: "PUT", headers, body: file }); if (!res.ok) throw new Error(`Upload failed: ${res.status}`); await fetch("/api/story0/uploads/complete", { method: "POST", body: JSON.stringify({ uploadId }) }); ``` --- # Segmentation > Label every moment of a video, or pull out only the moments you need, with timecodes, evidence and review flags. Segmentation turns a video into time intervals. You describe the task in plain language, and Story0 returns intervals in seconds, each with a label, the evidence behind it, and a review flag where a person should look. It costs $0.10 per minute of video in either mode. ## Partition or extract | | `partition` | `extract` | | --- | --- | --- | | Question it answers | "What is the state at every moment?" | "Where are the moments that match?" | | Labels | 2 to 12 of yours, plus the uncertainty label | None; every returned moment is labeled `match` | | Coverage | Every moment of the range is in exactly one interval | Only matching moments are returned; gaps are expected | | When nothing matches | Not applicable: the range is always covered | An empty `segments` list | | Typical uses | Visibility timelines, studio versus field footage, day versus night | Clip pulls, highlight candidates, finding one kind of action | | Interval IDs | `interval_1`, `interval_2`, … | `moment_1`, `moment_2`, … | Use `partition` when you need to know the state of the whole video, and `extract` when you only care about the parts that match. ## Create a segmentation This `extract` request finds jogging and keeps at most five moments of at least two seconds each: ```ts tab="TypeScript" const result = await story0.segments.createAndWait( { input: { asset_id: "asset_01m6gwgx34hq7d2m9ycv5k8b1e" }, mode: "extract", instruction: "Moments where a person is jogging", constraints: { min_seconds: 2, max_segments: 5 }, billing: { max_charge: { currency: "USD", amount: "0.25" } }, metadata: { story_id: "park-0412" }, }, { idempotencyKey: "park-0412:jogging:v1" }, ); ``` ```python tab="Python" result = client.segments.create_and_wait( input={"asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e"}, mode="extract", instruction="Moments where a person is jogging", constraints={"min_seconds": 2, "max_segments": 5}, billing={"max_charge": {"currency": "USD", "amount": "0.25"}}, metadata={"story_id": "park-0412"}, idempotency_key="park-0412:jogging:v1", ) ``` ```bash tab="cURL" curl https://api.story0.com/v1/segments \ -H "Authorization: Bearer $STORY0_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: park-0412:jogging:v1" \ -d '{ "input": { "asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e" }, "mode": "extract", "instruction": "Moments where a person is jogging", "constraints": { "min_seconds": 2, "max_segments": 5 }, "billing": { "max_charge": { "currency": "USD", "amount": "0.25" } }, "metadata": { "story_id": "park-0412" } }' ``` `createAndWait` returns the segmentation once it exists. `segments.create` (and the raw `POST`) returns the job immediately with `202 Accepted`, for pipelines that wait through [webhooks](/docs/webhooks) instead. For a complete `partition` example, see the [Quickstart](/docs/quickstart). ## Request fields | Field | Required | Description | | --- | --- | --- | | `input` | Yes | Exactly one of `asset_id` or `url`. See [Uploading media](/docs/uploads). | | `mode` | Yes | `partition` or `extract`. | | `instruction` | Yes | The task in plain language, 3 to 2,000 characters. | | `labels` | For `partition` | 2 to 12 `{ "key", "description" }` objects: the mutually exclusive states. Not allowed with `extract`. | | `uncertainty.label` | No | The label for moments without enough evidence. Default `uncertain`. | | `boundaries.snap` | No | Only `none` is available today: boundaries are never moved to shot cuts. | | `sampling.fps` | No | Frames per second to inspect, from 0.1 to 4. The default depends on the length of the range. | | `range` | No | `{ "start", "end" }` in seconds: analyze only this part of the video. | | `constraints` | No | For `extract`: `min_seconds`, `max_seconds` and `max_segments`. `partition` ignores them. | | `billing.max_charge` | No | The most this request may cost, such as `{ "currency": "USD", "amount": "0.50" }`. | | `metadata` | No | Up to 20 string key-value pairs (values up to 500 characters), echoed on the job and in its webhooks. | Send an `Idempotency-Key` header with every request; the SDKs add one automatically. See [Idempotency and retries](/docs/idempotency). ## Labels Each label is an object with a `key` and a `description`: ```json { "key": "dog_visible", "description": "At least one dog is visible, even partly out of frame." } ``` - **Keys** start with a lowercase letter and continue with lowercase letters, digits or underscores, up to 40 characters in all (`^[a-z][a-z0-9_]{0,39}$`). They appear in results, so keep them stable. - **Descriptions** are 1 to 500 characters. They tell Story0 what the label means; this is where your decision rules go. - Keys must be unique, and none may equal the uncertainty label. - For `partition`, the labels should be mutually exclusive, and together with the uncertainty label they should cover every possibility. If two things can be true at once, such as "dog visible" and "person visible", run two partitions. ### The uncertainty label Rather than forcing a guess, Story0 uses the uncertainty label where the frames don't show enough to choose: the subject is obscured, blurred, cut off or too dark, or the shot cuts away. Uncertain intervals always have `review.required: true` and never carry evidence. The default label is `uncertain`; rename it with `uncertainty.label` (same key format) if that word means something else in your taxonomy. ## The temporal contract ### Partition - Intervals are in time order and cover the whole range: the first starts at `range.start`, the last ends at `range.end`, and each one starts exactly where the previous one ended. There are no gaps and no overlaps. - Each interval has exactly one label, either one of yours or the uncertainty label. Neighboring intervals always differ, because consecutive moments with the same label are merged. - Intervals are start-inclusive and end-exclusive, `[start, end)`. A moment at exactly 4.5 seconds belongs to the interval that starts at 4.5. - Brief events are kept. A label seen in a single sampled frame gets its own interval, one frame interval long; nothing is smoothed away. Anything shorter than the frame interval can fall between samples and be missed, so raise `sampling.fps` if brief events matter. ### Extract - Moments are in time order. The gaps between them are moments that didn't match or couldn't be decided. - Every moment has `label: "match"` and a short `title`, taken from its first piece of evidence (or "Matching moment" when there's none). - A single uncertain frame between two matching frames doesn't split a moment. It's bridged, and the moment is flagged `partly_uncertain` with `review.required: true`. - No matches is a successful result with an empty `segments` list, not an error. ### Boundaries and resolution Story0 samples frames at an even rate and judges each one. A boundary falls halfway between two neighboring samples whose labels differ, so boundaries sit on a grid as fine as the sampling: `boundary_resolution_seconds`, which equals `coverage.frame_interval_seconds`. At the default 2 frames per second that's 0.5 seconds. Read a boundary at 4.0 as "the change happened between the frames at 3.75 and 4.25", not as a frame-exact cut. `boundaries.snap` accepts only `none` today. Boundaries are never moved to shot cuts, because a subject can enter or leave in the middle of an uninterrupted shot. ## Sampling By default the sampling rate depends on the length of the range you analyze: | Range length | Default rate | Boundary resolution | | --- | --- | --- | | Up to 2 minutes | 2 frames per second | 0.5 s | | Over 2 and up to 10 minutes | 1 frame per second | 1 s | | Over 10 minutes | About 600 frames spread across the range, for example 0.5 frames per second for 20 minutes | 1 / rate | Set `sampling.fps` between 0.1 and 4 to choose your own rate. Higher rates catch briefer events and give finer boundaries; lower rates are enough for long, slow footage. The rate doesn't change the price. > **Note:** Story0 inspects at most 600 frames per request. If `sampling.fps` multiplied by the length of the range would exceed that, the rate is lowered to fit, so the whole range is always covered; `coverage.frame_interval_seconds` reports the interval actually used. To look densely at part of a long video, combine a higher rate with a `range`. `coverage.sampled_frames` and `coverage.frame_interval_seconds` report what was actually inspected. `config.fps` echoes the rate you asked for, or `null` for the default. ## Analyze part of a video ```json "range": { "start": 120, "end": 180 } ``` - `start` defaults to 0 and `end` to the end of the video. When you give both, `end` must be greater than `start`. - An `end` past the end of the video is treated as the end of the video. - A range with less than a quarter of a second of video in it fails the job with `range_outside_media` (not charged). - A range shorter than half a frame interval contains no sampled frame, so it comes back as a single uncertain interval. - Intervals and evidence use the video's own timeline. An interval at 130–135 means 130 seconds from the start of the video, not of the range. - The result's `range` shows what was analyzed, and the default sampling rate depends on the range's length rather than the video's. - You're billed for the length of the range, not the whole video (still at least 30 seconds). ## Shape extracted moments `constraints` adjusts the moments `extract` returns: | Constraint | Allowed values | Effect | Review reason added | | --- | --- | --- | --- | | `min_seconds` | Over 0, up to 600 | Moments shorter than this are padded equally on both sides, without leaving the range. | `padded_to_min_seconds` | | `max_seconds` | Over 0, up to 3,600 | Longer moments are split into consecutive pieces of this length; the last piece can be shorter. | `split_at_max_seconds` | | `max_segments` | 1 to 500 | Keeps the longest moments, up to this many, still in time order. | None | `min_seconds` can't be greater than `max_seconds`. Story0 applies padding first, then splitting, then the count limit, so split pieces count individually toward `max_segments`. A few details to plan for: - Padding never merges moments, so two short moments close together can overlap once padded. - Near the start or end of the range, padding is cut off at the edge, so a padded moment can end up shorter than `min_seconds`. - When `max_segments` drops moments, the remaining ones keep their IDs, so you might see `moment_1` followed by `moment_3`. - `padded_to_min_seconds` and `split_at_max_seconds` are informational. On their own they don't set `review.required`. ## Read the result | Field | Description | | --- | --- | | `id`, `object` | `seg_…` and `segmentation`. | | `job_id`, `asset_id` | The job that produced the result and the asset it analyzed. | | `livemode`, `fixture` | `livemode` is `false` for test keys, and `fixture: true` appears only on [test-mode](/docs/test-mode) results. | | `mode` | `partition` or `extract`. | | `time_unit` | Always `seconds`. | | `duration_seconds` | The whole video's duration. | | `range` | The range analyzed, after defaults and clamping. | | `segments` | The intervals or moments: `id`, `start`, `end`, `label`, `title` (`extract` only), `review` and `evidence`. | | `coverage.timeline` | Always `complete`; see below. | | `coverage.sampled_frames` | How many frames were inspected. | | `coverage.frame_interval_seconds` | Time between samples. | | `coverage.uncertain_seconds` | Total time judged uncertain. | | `coverage.modalities` | What was analyzed: `["visual"]`. | | `boundary_resolution_seconds` | The grid that boundaries fall on. | | `config` | Your request as Story0 resolved it, defaults included, plus the engine version. | | `engine`, `provenance` | The engine version (`segment-2026-11`), whether the analysis was reused, and the API version. | | `receipt` | What the result cost. See [Pricing and billing](/docs/billing). | | `retention` | When the result and the media expire. | `coverage.timeline: "complete"` is a structural promise: the whole range was sampled, and in `partition` mode the intervals tile it exactly. It doesn't mean every frame of the video was inspected, and it says nothing about whether every label is right. ### Evidence Each evidence item is a sampled frame that supports its interval's label: ```json { "id": "ev_2", "kind": "seen", "start": 4.25, "end": 4.5, "description": "Golden retriever walking on a path in the park." } ``` - `start` is the time of the frame and `end` is where that frame's share of the timeline ends. Seek a player to `start` to show a reviewer what Story0 saw. - `kind` is `seen`: a visual observation. `description` is a short note about the frame. - `partition` intervals carry up to 2 items and `extract` moments up to 3. Uncertain intervals have none, and a definite interval can also have none. - Evidence supports a label at that moment. It isn't proof that the label holds for every frame of the interval: what happens between samples is inferred. - Evidence IDs (`ev_1`, `ev_2`, …) are unique within a result. ### Review flags Every interval and moment has `review: { "required": …, "reasons": [ … ] }`: | Reason | Mode | Sets `required` | Meaning | | --- | --- | --- | --- | | `insufficient_evidence` | `partition` | Yes | The interval has the uncertainty label. | | `partly_uncertain` | `extract` | Yes | The moment bridges an uncertain frame. | | `padded_to_min_seconds` | `extract` | No | The moment was padded to meet `min_seconds`. | | `split_at_max_seconds` | `extract` | No | The moment is one piece of a longer one split at `max_seconds`. | `review.required: false` means the analysis found no reason to flag the interval. It is not a clearance for licensing, publication or safety. ## Empty, uncertain and failed | What you see | What it means | | --- | --- | | A succeeded `extract` with an empty `segments` list | Nothing matched. It's billed like any other result. | | A succeeded `partition` with a single uncertain interval | Story0 sampled the video but couldn't decide anywhere. It's billed, and the interval needs review. | | A `failed` job | Story0 couldn't produce a result, for example because the media was unreadable or a URL couldn't be fetched. It's not billed; `job.error.code` says why. | A file that isn't readable video fails with `media_unreadable` when its media facts are read, before any analysis or charge; it never turns into an all-uncertain timeline. Within a readable video, frames that can't be decoded are reported as uncertain time, never as a negative label: in `partition` they become an uncertain interval flagged for review, and in `extract` a gap counted in `coverage.uncertain_seconds`. ## Reuse If you send the same segmentation request for the same media again, Story0 can answer from your account's earlier analysis instead of analyzing the video again. "The same" means: - the same media, recognized by its SHA-256 hash, so an identical file uploaded again counts; and - the same request: mode, instruction (ignoring leading and trailing spaces), labels in the same order, uncertainty label (defaults count, so omitting it equals sending `uncertain`), and `sampling.fps`, `range` and `constraints` as you sent them. `metadata` and `billing.max_charge` don't matter. A reused result has `provenance.analysis_reused: true` and the same segments, and it's charged only the $0.02-per-minute `segment` line instead of the full $0.10. Change anything about the request and Story0 analyzes the video again at the full price. Reuse stays within your account, and test-mode requests never use it. ## Write good instructions and labels Story0 judges each sampled frame on what it shows and then joins those judgments into intervals. Write for that. - **Describe what's visible.** "At least one dog is visible" works. "The dog is barking" depends on sound, which Story0 doesn't use. - **Say what counts and what doesn't.** "Count a dog that is partly out of frame. An audible bark alone does not count." Edge cases written into a description beat a guess. - **Keep partition labels exclusive.** `dog_visible` and `no_dog_visible` split every moment cleanly. "Dog" and "person" don't, because both can be on screen; run two partitions instead. - **Prefer states to events.** "A player is celebrating" can be seen in one frame; "a goal is scored" needs the moments before and after. For events, use `extract` with an instruction about what the frames show, such as "the ball crossing the goal line". - **Ask about what a viewer would notice.** Frames are inspected at a reduced size, so small on-screen text, distant faces and fine detail may not be legible. - **Change descriptions, keep keys.** Keys live in your data and dashboards. Refine behavior by editing a description, and version your idempotency keys when you do. - **Check before you scale.** Run a handful of representative videos, look at the uncertain intervals and the evidence, and tighten the descriptions where results surprise you. | Instead of | Try | | --- | --- | | `{ "key": "dog", "description": "Dog" }` | `{ "key": "dog_visible", "description": "At least one dog is visible, even partly out of frame. Toys and drawings of dogs do not count." }` | | "Find the good bits" | "Moments where a person is jogging along the path" | | One partition with `dog`, `person`, `both`, `neither` | Two partitions: `dog_visible` / `no_dog_visible` and `person_visible` / `no_person_visible` | ## Edge cases - **One label throughout:** a single interval covering the whole range. - **Uncertain throughout:** a single interval with the uncertainty label and `review.required: true`. - **Very short videos:** billed as 30 seconds. At 2 frames per second, a 3-second clip gives 6 frames. - **A range that runs past the end:** `end` is clamped to the video's duration. Keep `start` inside the video. - **A tiny range:** a range shorter than half a frame interval contains no sample, so it comes back as one uncertain interval. ## Record reviewer feedback (preview) When a person checks a result, you can record their verdict with `POST /v1/feedback`. Feedback is append-only and never changes the result it refers to. ```ts tab="TypeScript" await story0.feedback.create({ result_id: "seg_01m6gwj2c15y8n3q7w2e9t4h6b", segment_id: "interval_3", kind: "relabel", label: "dog_visible", start: 15, end: 15.5, actor: "editor@example.com", comment: "The dog's tail is still in frame at the left edge.", }); ``` ```python tab="Python" client.feedback.create( result_id="seg_01m6gwj2c15y8n3q7w2e9t4h6b", segment_id="interval_3", kind="relabel", label="dog_visible", start=15, end=15.5, actor="editor@example.com", comment="The dog's tail is still in frame at the left edge.", ) ``` `kind` is `correct`, `incorrect`, `relabel` (which needs the reviewer's `label`) or `note`. Point it at a `segment_id` from the result, optionally with `start` and `end` in seconds, a `comment` of up to 2,000 characters and an `actor` in your own terms. For screenings, use `category` instead of `segment_id`. Without an `Idempotency-Key`, a repeated call records the feedback twice; with one, the repeat returns the first record. --- # Screening > Check a whole video against up to 20 categories at once and route it on match, no_match or uncertain. Screening answers a quick question about a whole video: which of these categories does it show? For each category you get `match`, `no_match` or `uncertain`, a short observation, the frames that support a match, and a review flag. At $0.02 per minute, a fifth of the price of segmentation, it's built for the first pass: routing, triage and deciding what deserves a closer look. ## Create a screening ```ts tab="TypeScript" const screening = await story0.screenings.createAndWait({ input: { asset_id: "asset_01m6gwgx34hq7d2m9ycv5k8b1e" }, categories: [ { key: "animals", description: "Animals doing something fun or surprising" }, { key: "breaking_news", description: "Breaking news, emergencies or police activity" }, { key: "people", description: "People visible on screen" }, ], billing: { max_charge: { currency: "USD", amount: "0.10" } }, }); console.log(screening.matched); // ["animals", "people"] ``` ```python tab="Python" screening = client.screenings.create_and_wait( input={"asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e"}, categories=[ {"key": "animals", "description": "Animals doing something fun or surprising"}, {"key": "breaking_news", "description": "Breaking news, emergencies or police activity"}, {"key": "people", "description": "People visible on screen"}, ], billing={"max_charge": {"currency": "USD", "amount": "0.10"}}, ) print(screening["matched"]) # ['animals', 'people'] ``` ```bash tab="cURL" curl https://api.story0.com/v1/screenings \ -H "Authorization: Bearer $STORY0_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: clip-118:screen:v1" \ -d '{ "input": { "asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e" }, "categories": [ { "key": "animals", "description": "Animals doing something fun or surprising" }, { "key": "breaking_news", "description": "Breaking news, emergencies or police activity" }, { "key": "people", "description": "People visible on screen" } ], "billing": { "max_charge": { "currency": "USD", "amount": "0.10" } } }' ``` Like segmentation, the request returns a job (`202 Accepted`); the SDK helpers wait for it and return the screening. You can use `input.url` instead of `input.asset_id`, and `metadata` is echoed on the job. See [Jobs and results](/docs/jobs). ## Categories - Send between 1 and 20 categories, each a `{ "key", "description" }` object. - Keys follow the same rules as segmentation labels: a lowercase letter, then lowercase letters, digits or underscores, up to 40 characters in all. Keys must be unique. - Descriptions are 1 to 500 characters and say what counts. - Categories are independent. A video can match several of them, or none. ## Read the result ```json { "id": "scr_01m6gwn3a8x2v6m9k4t7c1r5q8", "object": "screening", "job_id": "job_01m6gwmtz4c7n2k9w4r8t3d6x1", "asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e", "livemode": true, "engine": "screen-2026-11", "time_unit": "seconds", "duration_seconds": 24, "categories": [ { "key": "animals", "outcome": "match", "score": { "value": 1, "kind": "model_estimate", "calibrated": false }, "observation": "A golden retriever dog is running and catching a frisbee in the park.", "evidence": [ { "kind": "seen", "start": 6, "end": 8.4 }, { "kind": "seen", "start": 8.4, "end": 10.8 }, { "kind": "seen", "start": 10.8, "end": 13.2 }, { "kind": "seen", "start": 13.2, "end": 15.6 } ], "review": { "required": false, "reasons": [] } }, { "key": "breaking_news", "outcome": "no_match", "score": { "value": 0, "kind": "model_estimate", "calibrated": false }, "observation": "Peaceful park setting with no breaking news or emergencies.", "evidence": [], "review": { "required": false, "reasons": [] } }, { "key": "people", "outcome": "match", "score": { "value": 1, "kind": "model_estimate", "calibrated": false }, "observation": "A person is walking along the path in the park.", "evidence": [ { "kind": "seen", "start": 15.6, "end": 18 }, { "kind": "seen", "start": 18, "end": 20.4 } ], "review": { "required": false, "reasons": [] } } ], "matched": ["animals", "people"], "coverage": { "sampled_frames": 10, "modalities": ["visual"] }, "config": { "categories": [ { "key": "animals", "description": "Animals doing something fun or surprising" }, { "key": "breaking_news", "description": "Breaking news, emergencies or police activity" }, { "key": "people", "description": "People visible on screen" } ], "engine": "screen-2026-11" }, "provenance": { "engine": "screen-2026-11", "analysis_reused": false, "api_version": "2026-11-01" }, "receipt": { "currency": "USD", "total": "0.01", "items": [{ "item": "screen", "amount": "0.01", "unit_price_per_minute": "0.02" }], "billable_seconds": 30, "price_version": "2026-11-01" }, "retention": { "result_expires_at": "2026-12-03T14:22:51.730Z", "media_expires_at": "2026-11-10T14:20:31.204Z" }, "created_at": "2026-11-03T14:22:51.730Z" } ``` | Field | Description | | --- | --- | | `categories` | One entry per category, in the order you sent them. | | `categories[].outcome` | `match`, `no_match` or `uncertain`. | | `categories[].score` | `{ "value", "kind": "model_estimate", "calibrated": false }`; see below. | | `categories[].observation` | A short note about what decided the outcome, up to 200 characters. | | `categories[].evidence` | For a `match`, up to 4 frames that support it, as `{ "kind": "seen", "start", "end" }`. Empty otherwise. | | `categories[].review` | `required: true` for an `uncertain` outcome, with the reason. | | `matched` | The keys whose outcome is `match`, in request order. | | `coverage` | `sampled_frames` (how many frames were inspected) and `modalities` (`["visual"]`). | | `duration_seconds`, `config`, `engine`, `provenance`, `receipt`, `retention` | As for segmentation. `fixture: true` appears on test-mode results. | ### Outcomes - **`match`:** the frames clearly show the category. `evidence` lists frames that support it. - **`no_match`:** the frames clearly don't show it. - **`uncertain`:** the frames aren't enough to decide either way. `review.required` is `true` with the reason `insufficient_evidence`. If the analysis returns no answer for a category at all, the outcome is also `uncertain`, with the reason `not_answered` and a score of 0.5. A missing answer never becomes a silent `no_match`. Route `uncertain` to a person rather than treating it as `no_match`. ### Scores are estimates `score.value` is a number from 0 to 1: the analysis's own estimate that the category applies, rounded to two decimals. The other two fields say what it is: `kind: "model_estimate"` and `calibrated: false`. It isn't a probability, it hasn't been calibrated against your footage, and it shouldn't be compared across categories or engine versions. Values of exactly 0 or 1 are common and don't mean certainty. Make decisions on `outcome`; use `score` at most to sort videos within one category. ## How screening looks at a video Screening inspects between 6 and 16 frames spread evenly across the whole video: about one every 2.5 seconds, so a 24-second clip gets 10 frames, clips under 15 seconds get 6, and videos of 40 seconds or more get 16. That's what makes it quick and cheap, and it also means brief moments between those frames can be missed. Screening always covers the whole video (there's no `range`), and like segmentation it's visual only. Each evidence entry covers one frame's slot of the timeline: `start` is the time of the frame and `end` is the start of the next slot. Seek a player to `start` to show a reviewer the frame behind a match. If you need to know where in the video something happens, or you can't afford to miss brief moments, use [segmentation](/docs/segments). ## Screen first, then segment A typical pipeline screens everything cheaply, segments only what matched, and sends anything uncertain to a person: ```ts tab="TypeScript" const screening = await story0.screenings.createAndWait( { input: { asset_id: assetId }, categories: [ { key: "animals", description: "Animals doing something fun or surprising" }, { key: "breaking_news", description: "Breaking news, emergencies or police activity" }, ], }, { idempotencyKey: `${assetId}:screen:v1` }, ); if (screening.matched.includes("animals")) { const moments = await story0.segments.createAndWait( { input: { asset_id: assetId }, mode: "extract", instruction: "Moments where an animal is doing something fun or surprising", constraints: { min_seconds: 3 }, }, { idempotencyKey: `${assetId}:animal-moments:v1` }, ); await queueForEditors(assetId, moments.segments); } else if (screening.categories.some((c) => c.review.required)) { await queueForReview(assetId, screening); } ``` ```python tab="Python" screening = client.screenings.create_and_wait( input={"asset_id": asset_id}, categories=[ {"key": "animals", "description": "Animals doing something fun or surprising"}, {"key": "breaking_news", "description": "Breaking news, emergencies or police activity"}, ], idempotency_key=f"{asset_id}:screen:v1", ) if "animals" in screening["matched"]: moments = client.segments.create_and_wait( input={"asset_id": asset_id}, mode="extract", instruction="Moments where an animal is doing something fun or surprising", constraints={"min_seconds": 3}, idempotency_key=f"{asset_id}:animal-moments:v1", ) queue_for_editors(asset_id, moments["segments"]) elif any(c["review"]["required"] for c in screening["categories"]): queue_for_review(asset_id, screening) ``` `queueForEditors` and `queueForReview` stand in for your own code. Creating the asset once and reusing its `asset_id` means the video is uploaded or fetched only once for both requests. ## Cost Screening costs $0.02 per minute of video, with a 30-second minimum per video, rounded up to the whole second: $0.01 for anything up to 30 seconds, $0.20 for 10 minutes, and $0.40 for 20 minutes, the longest video supported. A repeat of an identical screening on the same media can be answered from your earlier analysis (`provenance.analysis_reused: true`), but screenings are always charged the full $0.02 per minute; reuse lowers the price of segmentation only. See [Pricing and billing](/docs/billing). ## Write good categories - **Describe what a viewer would see.** "Police cars, fire engines or ambulances at a scene" works better than "emergency". - **Keep each category to one idea.** Split "animals or sport" into two categories; you can send up to 20 at once. - **Put exclusions in the description.** "Pets at home count. Zoo signage on its own does not." - **Keep keys stable.** Routing rules and dashboards depend on them; refine the description instead. - **Don't rely on sound or small text.** Screening is visual and inspects reduced-size frames, so audio and fine print aren't part of the decision. --- # 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](https://dev.story0.com/console), or with the API: ```ts tab="TypeScript" 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. ``` ```python tab="Python" 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. ``` ```bash tab="cURL" 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. ```ts tab="Express" 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); }); ``` ```ts tab="Next.js" // 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 }); } ``` ```python tab="FastAPI" 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) ``` ```python tab="Flask" 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: ```ts tab="Node.js" import { createHmac, timingSafeEqual } from "node:crypto"; export function verifyStory0(rawBody: string, headers: Record, 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); }); } ``` ```python tab="Python" 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. ```ts tab="TypeScript" 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); } } ``` ```python tab="Python" 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 ```ts tab="TypeScript" await story0.webhookEndpoints.test("whe_01m6gvmve09a4t7c2k6r1x8m3q"); ``` ```python tab="Python" client.webhook_endpoints.test("whe_01m6gvmve09a4t7c2k6r1x8m3q") ``` ```bash tab="cURL" 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: ```ts tab="TypeScript" 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); ``` ```python tab="Python" 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"] ``` ```bash tab="cURL" 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. --- # Jobs and results > How processing requests run in the background, how to wait for them, cancel them and recover after a disconnect. Every processing request, `POST /v1/segments` or `POST /v1/screenings`, creates a job and returns it at once. The job does the work in the background. When it succeeds, it points to a typed, immutable result: a `segmentation` or a `screening`. Jobs are the only asynchronous part of the API, so once you know how to wait for one, you know how to use every operation. ## The job object ```json { "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" } } ``` | Field | Description | | --- | --- | | `id` | The job ID, `job_…`. | | `operation` | `segment` or `screen`. | | `status` | `queued`, `running`, `succeeded`, `failed` or `cancelled`. | | `livemode` | `true` for live keys, `false` for test keys. | | `request_id` | The ID of the request that created the job. | | `asset_id` | The asset the job works on, including one Story0 created for an `input.url`. | | `stage` | What a running job is doing now. `null` unless `running`. | | `progress` | A number from 0 to 1 while running, 1 once succeeded, `null` otherwise. | | `result` | `{ "id", "object", "url" }` once the job has succeeded, `null` before that. | | `error` | `{ "code", "title", "detail", … }` when the job failed, otherwise `null`. | | `charge` | `estimate` (the full price, or `null` if the duration wasn't known when the job was accepted), `max_charge` (the cap applied), and `total` (what was charged, `null` until the job ends). | | `created_at`, `started_at`, `finished_at` | When the job was accepted, first started and ended. | | `metadata` | The key-value pairs you sent with the request. | ## Lifecycle | Status | Meaning | Final | | --- | --- | --- | | `queued` | Accepted and waiting to start. | No | | `running` | In progress. `stage` and `progress` say where it is. | No | | `succeeded` | The result exists, and `result` points to it. | Yes | | `failed` | There is no result, and `error` says why. Nothing is charged. | Yes | | `cancelled` | Stopped at your request before it finished. Nothing is charged. | Yes | A job moves from `queued` to `running` and then to one of the three final states, or straight from `queued` to `cancelled`. Final states never change. ### Stages While a job is `running`, `stage` names its current step: | Stage | What's happening | | --- | --- | | `starting` | The job has been picked up. | | `ingesting` | Story0 reads the media, fetching it first for a URL input and reading its media facts if they aren't known yet. | | `sampling` | Frames are taken from the video. Screening reports this stage; segmentation samples and analyzes in overlapping chunks, so it goes straight to `analysing`. | | `analysing` | The frames are analyzed. Segmentation advances `progress` as each chunk finishes. | | `assembling` | The result is being stored and the charge settled. | | `retrying` | A step hit a temporary problem. Story0 runs the job again automatically, up to five attempts in all, before failing it. | Treat `stage` and `progress` as information for display. New stages can be added, `progress` isn't a time estimate, and a retried job starts again from the beginning. ## Create a job The response to a processing request is the job, with `202 Accepted`, a `Location` header and a `Retry-After` hint in seconds: ```http HTTP/1.1 202 Accepted Content-Type: application/json Location: /v1/jobs/job_01m6gwhj05rk4v9c2x7m3d8f6p Retry-After: 2 ``` The `Location` path is relative to `https://api.story0.com`. If the request repeats an earlier one with the same `Idempotency-Key`, the response is `200 OK` with the original job, in whatever state it's in now, instead of a new job. See [Idempotency and retries](/docs/idempotency). ## Wait for a job There are three ways to wait. Choose by where your code runs. ### Long-poll `GET /v1/jobs/{id}?wait=N` holds the request for up to N seconds (0 to 30) and answers as soon as the job reaches a final state, so you see completion within a fraction of a second without polling rapidly. If the job is still going when the wait runs out, you get its current state with `Retry-After: 1`; just call again. ```bash curl "https://api.story0.com/v1/jobs/job_01m6gwhj05rk4v9c2x7m3d8f6p?wait=20" \ -H "Authorization: Bearer $STORY0_API_KEY" ``` Without `wait`, the request answers immediately. Set your HTTP client's timeout comfortably above the wait; the SDKs add 30 seconds to it. A long-poll counts as a single request against your rate limit however long it waits. ### SDK helpers ```ts tab="TypeScript" const params = { input: { asset_id: "asset_01m6gwgx34hq7d2m9ycv5k8b1e" }, mode: "extract" as const, instruction: "Moments where a person is jogging", }; const result = await story0.segments.createAndWait(params, { idempotencyKey: "park-0412:jogging-progress:v1", waitTimeoutMs: 15 * 60_000, onProgress: (job) => console.log(job.stage, job.progress), }); ``` ```python tab="Python" params = { "input": {"asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e"}, "mode": "extract", "instruction": "Moments where a person is jogging", } result = client.segments.create_and_wait( **params, idempotency_key="park-0412:jogging-progress:v1", wait_timeout=15 * 60, on_progress=lambda job: print(job["stage"], job["progress"]), ) ``` - `createAndWait` (`create_and_wait`) submits the request, waits, and returns the typed result. It raises `JobFailedError` if the job fails, `JobCancelledError` if it's cancelled, and `WaitTimeoutError` if the wait timeout (10 minutes by default) runs out first. - `jobs.wait(id)` waits for a job you already have and returns it in its final state, whatever that is. It doesn't raise for a failed or cancelled job, only `WaitTimeoutError` when the wait runs out. - Both long-poll in holds of up to 20 seconds, or 2 seconds when you pass a progress callback, so the callback sees each stage. ### Webhooks For background pipelines, subscribe an endpoint to `job.succeeded`, `job.failed` and `job.cancelled`. `job.succeeded` is the single completion event, and it fires only once the result exists. See [Webhooks](/docs/webhooks). ## Get the result A succeeded job's `result` says which kind of result it produced and where to fetch it: | Result | `result.object` | Endpoint | SDK | | --- | --- | --- | --- | | Segmentation | `segmentation` | `GET /v1/segments/{id}` | `segments.retrieve(id)` | | Screening | `screening` | `GET /v1/screenings/{id}` | `screenings.retrieve(id)` | Results are immutable: fetching one twice returns the same content. They're kept for 30 days (`retention.result_expires_at`). After that the endpoint answers `410 result_expired`, and you can run the request again to produce a new result, so store what you need. Result URLs require your API key; they aren't share links. ## When a job fails A failed job looks like this (other fields omitted): ```json { "id": "job_01m6gwq8r2v5k9c3x7m1d4f6t8", "object": "job", "status": "failed", "error": { "code": "source_unavailable", "title": "The URL could not be fetched", "detail": "The server answered 404." }, "charge": { "currency": "USD", "estimate": null, "max_charge": "0.50", "total": "0.00" } } ``` A `GET` that returns a failed job is still `200 OK`: the request worked, the job didn't. Branch on `error.code`; [Errors](/docs/errors) lists every code. Failed jobs are never charged, and their hold is released. A failed job is final. To try again, send a new request with a new idempotency key, because the old key returns the same failed job. ## Cancel a job ```ts tab="TypeScript" const job = await story0.jobs.cancel("job_01m6gwhj05rk4v9c2x7m3d8f6p"); ``` ```python tab="Python" job = client.jobs.cancel("job_01m6gwhj05rk4v9c2x7m3d8f6p") ``` ```bash tab="cURL" curl -X POST https://api.story0.com/v1/jobs/job_01m6gwhj05rk4v9c2x7m3d8f6p/cancel \ -H "Authorization: Bearer $STORY0_API_KEY" ``` - A `queued` job is cancelled at once and costs nothing. - A `running` job is asked to stop. It checks between stages: after the media is ingested, and again after the analysis, before the result is stored. The response still shows `running`, so wait for the final state: `cancelled` (not charged), or `succeeded` (charged normally) if the job reached its last step first. - Cancelling a job that has already finished changes nothing and returns it as it is. Closing a connection or aborting an SDK wait doesn't cancel anything; the job keeps running. Only the cancel endpoint stops it. ## List jobs `GET /v1/jobs` returns jobs newest first, 20 per page by default (`limit` accepts 1 to 100). Filter with `status`, and page with `cursor` and `next_cursor`. The list includes jobs from both test and live keys, so check `livemode`. ```ts tab="TypeScript" const running = await story0.jobs.list({ status: "running", limit: 100 }); for (const job of running.data) console.log(job.id, job.stage, job.progress); ``` ```python tab="Python" running = client.jobs.list(status="running", limit=100) for job in running["data"]: print(job["id"], job["stage"], job["progress"]) ``` ```bash tab="cURL" curl "https://api.story0.com/v1/jobs?status=running&limit=100" \ -H "Authorization: Bearer $STORY0_API_KEY" ``` ## Request IDs Every response carries an `x-request-id` header, every error body a matching `request_id`, and every job the `request_id` of the request that created it. You can send your own ID in an `X-Request-Id` header (8 to 80 letters, digits, `_` or `-`), and Story0 uses it instead of generating one, which makes it easy to follow a request through your logs. ## Recover after a disconnect If your process restarts or a connection drops while you wait, the job keeps running. Pick up where you left off: - If you have the job ID, call `jobs.wait(id)` or long-poll `GET /v1/jobs/{id}?wait=20`. - If you don't, send the same request again with the same idempotency key. You get the same job back with `200 OK`, not a new one, and nothing is charged twice. `WaitTimeoutError` carries the job ID for exactly this case: ```ts tab="TypeScript" import Story0, { WaitTimeoutError } from "@story0/sdk"; const story0 = new Story0(); async function jogging(assetId: string) { const params = { input: { asset_id: assetId }, mode: "extract" as const, instruction: "Moments where a person is jogging", }; try { return await story0.segments.createAndWait(params, { idempotencyKey: `${assetId}:jogging:v1`, waitTimeoutMs: 60_000, }); } catch (err) { if (!(err instanceof WaitTimeoutError)) throw err; const job = await story0.jobs.wait(err.jobId); // keep waiting; nothing is resubmitted if (job.status !== "succeeded" || !job.result) throw new Error(`Job ${job.id} ended ${job.status}`); return story0.segments.retrieve(job.result.id); } } ``` ```python tab="Python" import story0 from story0 import Story0 client = Story0() def jogging(asset_id: str): params = { "input": {"asset_id": asset_id}, "mode": "extract", "instruction": "Moments where a person is jogging", } try: return client.segments.create_and_wait( **params, idempotency_key=f"{asset_id}:jogging:v1", wait_timeout=60 ) except story0.WaitTimeoutError as err: job = client.jobs.wait(err.job_id) # keep waiting; nothing is resubmitted if job["status"] != "succeeded": raise RuntimeError(f"Job {job['id']} ended {job['status']}") return client.segments.retrieve(job["result"]["id"]) ``` --- # Idempotency and retries > Retry processing requests safely, so a lost response never runs or bills the same work twice. Networks fail at the worst moments. A request reaches Story0, the job starts, and the response never makes it back. If you simply send the request again, you might start, and pay for, a second job. Idempotency keys prevent that: send the same key with the same request, and Story0 returns the job it already created. ## Send a key Add an `Idempotency-Key` header to any request that creates something. It matters most for the processing requests, `POST /v1/segments` and `POST /v1/screenings`, because those run and bill work: ```bash curl https://api.story0.com/v1/screenings \ -H "Authorization: Bearer $STORY0_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: feed-42:clip-118:screen:v1" \ -d '{ "input": { "asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e" }, "categories": [{ "key": "animals", "description": "Animals doing something fun or surprising" }] }' ``` A key is 1 to 255 characters drawn from `A–Z`, `a–z`, `0–9`, `_`, `.`, `:` and `-`. Anything else is refused with `422 invalid_request`. Keys belong to your account. They're shared by every endpoint and by your test and live keys, so one key always identifies exactly one request: a key you used to create an asset can't also start a segmentation. ## What happens when you repeat a key For processing requests: | You send | Story0 answers | | --- | --- | | A key it hasn't seen | `202 Accepted` with a new job. The key is now tied to that request. | | The same key with the same request | `200 OK` with the original job, in its current state. Nothing new starts and nothing is charged again. | | The same key with a different request | `422 idempotency_key_reused`. Nothing is created. | | The same key more than 7 days later | A new job, because Story0 remembers keys for 7 days. | A request that's rejected, for example with `422 invalid_request`, `402 insufficient_credits` or `429 rate_limited`, stores nothing under its key. Fix the problem and send the request again with the same key. ### What counts as the same request Story0 compares: - the operation, segmentation or screening; - `input`: the same `asset_id`, or exactly the same `url` string; - the request with defaults applied: for segmentation the mode, the instruction (ignoring leading and trailing spaces), the labels in order, the uncertainty label, `sampling`, `range` and `constraints`; for screening the categories in order; - `billing.max_charge`, compared as an amount, so `"0.5"` and `"0.50"` match; and - whether a live or a test key sent it. The order of properties in your JSON doesn't matter. `metadata` isn't compared: a repeat with different metadata returns the original job, with the original metadata. > **Tip:** If your inputs are signed URLs that change every time you generate them, a retry with a fresh URL is a different request. Create an asset first and pass its `asset_id`, which stays the same. ### Repeating a request that already finished A repeat returns the job as it is now. If the job succeeded, the response points at its result. If it failed, you get the failed job: a repeat never runs the work again. To try a failed request again, send it with a new key. ## Which endpoints use keys The other create endpoints, `POST /v1/uploads`, `POST /v1/assets`, `POST /v1/webhook_endpoints` and `POST /v1/feedback`, honor keys too. The same key with the same JSON body returns the first response again, exactly as it was then: the same upload session, the same asset, the same webhook endpoint (including its signing secret) or the same feedback record. Because it's the original response, an asset replayed this way can still show `processing`; fetch it to see its current state. The same key with a different body is refused with `422 idempotency_key_reused`, and a request that failed stores nothing under its key. Here's how every endpoint behaves when repeated: | Endpoint | When repeated | | --- | --- | | `POST /v1/segments`, `POST /v1/screenings` | With the same key, returns the original job in its current state, as described above. | | `POST /v1/uploads`, `POST /v1/assets`, `POST /v1/webhook_endpoints`, `POST /v1/feedback` | With the same key, returns the first response. Without a key, each call creates something new: another upload session, another asset (fetching the URL again), another endpoint with its own secret, or another feedback record. | | `POST /v1/uploads/{id}/complete` | Safe with or without a key: completing the same upload again returns the same asset. | | `POST /v1/jobs/{id}/cancel` | Safe: cancelling again has no further effect. | | `POST /v1/estimates` | Safe: an estimate changes nothing. | | `POST /v1/webhook_endpoints/{id}/test` | Sends another ping. | | `DELETE /v1/webhook_endpoints/{id}` | Safe: returns the same disabled endpoint. | | `DELETE /v1/assets/{id}` | The repeat returns `404 not_found`, because the asset is already gone. | ## Keys in the SDKs Both SDKs add a key to every processing request and to upload, asset and webhook-endpoint creation, a random UUID unless you pass your own, and keep that key across their own automatic retries. A dropped connection inside one call therefore never creates a second job, asset or endpoint. Feedback is the exception: the SDKs send a key, and retry, only when you pass one. A generated key only lives as long as that call. To stay safe across process restarts, crashes and task queues that re-run work, pass your own key: ```ts tab="TypeScript" const job = await story0.segments.create( { input: { asset_id: assetId }, mode: "partition", instruction: "Partition the video by whether a dog is visible.", labels: [ { key: "dog_visible", description: "At least one dog is visible." }, { key: "no_dog_visible", description: "No dog is visible." }, ], }, { idempotencyKey: `story-${storyId}:dog-timeline:v3` }, ); ``` ```python tab="Python" job = client.segments.create( input={"asset_id": asset_id}, mode="partition", instruction="Partition the video by whether a dog is visible.", labels=[ {"key": "dog_visible", "description": "At least one dog is visible."}, {"key": "no_dog_visible", "description": "No dog is visible."}, ], idempotency_key=f"story-{story_id}:dog-timeline:v3", ) ``` The same applies to `createAndWait` and `create_and_wait`: if your worker dies while waiting and the task runs again, the same key returns the same job, and the helper simply waits for it. ## Choose good keys Build keys from your own identifiers plus a version of the question, so the same logical request always produces the same key: - `story-8812:dog-timeline:v3` - `feed-42:clip-118:screen:v1` - `quickstart-asset_01m6gwgx34hq7d2m9ycv5k8b1e` When you change the instruction, labels or cap, bump the version part. Don't put timestamps or random values in keys that should deduplicate retries, and never reuse a key for a different request. ## Retry the right failures | Failure | Retry? | How | | --- | --- | --- | | Network error or timeout | Yes | Same key. The SDKs do this for you. | | `429 rate_limited` | Yes, after a pause | Back off exponentially; same key. If you hit the limit of 20 active jobs, wait for some to finish first. | | `500 internal_error` or another 5xx | Yes | Same key, with backoff. | | `402 insufficient_credits` or `402 charge_cap_exceeded` | After fixing | Add credits or raise `billing.max_charge`. The same key still works, because nothing was stored. | | `422 invalid_request`, `unsupported_input`, `asset_failed` | After fixing | Correct the request first. | | `422 idempotency_key_reused` | No | The key belongs to a different request. Use a new key. | | `401`, `403` or `404` | No | Fix the key, the permissions or the ID. | | A job that ended `failed` | With a new key | Read `job.error.code`. If the cause is temporary (`capacity_exhausted`, `model_failed`, `model_unavailable`, `processing_failed`), send the request again later with a new key. | The SDKs retry network errors, `429` and `5xx` responses up to 2 times by default (`maxRetries` in TypeScript, `max_retries` in Python), with exponential backoff and jitter, and they honor a `Retry-After` header of up to 20 seconds. They retry only reads and requests that carry an idempotency key, so a request that might have created something is never sent twice under different keys. --- # Pricing and billing > What each operation costs, how credits, estimates and charge caps work, and how to read a receipt. Story0 charges per minute of video processed, from prepaid credits. You can quote any request before you send it, cap what it may cost, and read an itemized receipt on every result. Failed and cancelled jobs cost nothing, and test keys never bill. ## Prices | Operation | Price per minute of video | Receipt lines | | --- | --- | --- | | Screening | $0.02 | `screen` $0.02 | | Segmentation, `partition` or `extract` | $0.10 | `analysis` $0.08 and `segment` $0.02 | These are price version `2026-11-01`, which every estimate and receipt names in `price_version`. There are no charges for uploading, for storing media during its retention period, for estimates, for webhooks, or for reading jobs and results. ## How a charge is calculated - **Billable time** is the length of video analyzed, rounded up to a whole second, with a minimum of 30 seconds per request: the whole video for screenings, and the requested `range` (or the whole video) for segmentations. - **Each line** is its per-minute price multiplied by the billable seconds and divided by 60. - **Amounts** are calculated in millionths of a dollar and returned as decimal strings with two to four decimal places, so receipt lines can differ from the total by rounding in the last place. | Video length | Billable seconds | Segmentation | Screening | | --- | --- | --- | --- | | 8 seconds | 30 | $0.05 | $0.01 | | 24 seconds | 30 | $0.05 | $0.01 | | 45 seconds | 45 | $0.075 | $0.015 | | 2 minutes 10.4 seconds | 131 | $0.2183 | $0.0437 | | 10 minutes | 600 | $1.00 | $0.20 | | 20 minutes, the longest supported | 1,200 | $2.00 | $0.40 | ## Credits You pay from a prepaid balance of credits. 100 credits are worth $1, so a $0.05 segmentation uses 5 credits. - **Free credit:** every new account receives $10 (1,000 credits) once its email address is verified. - **Buying credit:** choose $10, $25, $50, $100 or $250, or any whole-dollar amount from $10 to $1,000, on the Billing page of the console ([dev.story0.com/console/billing](https://dev.story0.com/console/billing)). Payment goes through a hosted Stripe checkout and the credits appear as soon as it is confirmed. - **No expiry:** credits don't expire. If your available balance can't cover a request, Story0 refuses it with `402 insufficient_credits` and nothing runs. ## Estimates Quote an operation for a ready asset, or for any duration you name: ```ts tab="TypeScript" const estimate = await story0.estimates.create({ operation: "segment", input: { asset_id: "asset_01m6gwgx34hq7d2m9ycv5k8b1e" }, }); console.log(estimate.total); // "0.05" ``` ```python tab="Python" estimate = client.estimates.create( operation="segment", input={"asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e"}, ) print(estimate["total"]) # 0.05 ``` ```bash tab="cURL" curl https://api.story0.com/v1/estimates \ -H "Authorization: Bearer $STORY0_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "operation": "segment", "input": { "asset_id": "asset_01m6gwgx34hq7d2m9ycv5k8b1e" } }' ``` For the 24-second sample clip, the estimate is: ```json { "object": "estimate", "operation": "segment", "billable_seconds": 30, "currency": "USD", "total": "0.05", "items": [ { "item": "analysis", "amount": "0.04", "unit_price_per_minute": "0.08" }, { "item": "segment", "amount": "0.01", "unit_price_per_minute": "0.02" } ], "price_version": "2026-11-01", "expires_at": "2026-11-03T15:20:41.502Z" } ``` `operation` is `segment` or `screen`, and `input` takes either `asset_id` (the asset must be `ready`, so its duration is known) or `duration_seconds`, such as `{ "duration_seconds": 600 }` to price a 10-minute video before you have it. An estimate is a quote, not a reservation: it's meant to be current for an hour (`expires_at`), and the price is fixed again when Story0 accepts the actual request. Estimates always quote the full price; if [analysis reuse](/docs/segments) applies, the charge comes out lower. ## Cap a request with max_charge Every processing request accepts a ceiling on what it may cost: ```json "billing": { "max_charge": { "currency": "USD", "amount": "0.50" } } ``` `amount` is a decimal string with up to six decimal places, and `currency` must be `USD`. Story0 never charges more than the cap: - **When the duration is known** (the input is a `ready` asset), Story0 prices the request before accepting it. If the price is above the cap, the request is refused with `402 charge_cap_exceeded`, which reports the `estimate` and your `max_charge`. Nothing is created or charged. - **When the duration isn't known yet** (an `input.url`, or an asset that's still `processing`), Story0 accepts the request and checks the price as soon as it has read the media. If the price is above the cap, the job fails with `charge_cap_exceeded` before any analysis, and nothing is charged. Without `max_charge`, the cap is the estimate when the duration is known, and $2.00 otherwise, which is what segmenting a 20-minute video costs. ## Holds and settlement Story0 reserves money when it accepts a job, so concurrent jobs can never spend more than your balance: 1. **On acceptance,** Story0 places a hold for the most the job can cost: the full price when the duration is known, or the whole cap when it isn't. If your available balance is smaller than the hold, the request is refused with `402 insufficient_credits`, which reports what was `needed` and what is `available`. 2. **While the job runs,** the hold appears in your balance's `held` amount and is excluded from `available`. 3. **When the job succeeds,** Story0 charges the actual price, never more than the hold, and releases the rest. 4. **When the job fails or is cancelled,** the whole hold is released and nothing is charged. So a URL input with a $0.50 cap holds $0.50 while it runs, and if the video turns out to cost $0.05, $0.45 goes back to `available` when the job ends. > **Tip:** If your available balance is below $2.00 and you send URL inputs, set `max_charge`. Otherwise the default $2.00 hold can be refused with `insufficient_credits` even though the video itself would cost much less. ## Receipts A job shows its charge in `charge`: ```json "charge": { "currency": "USD", "estimate": "0.05", "max_charge": "0.50", "total": "0.05" } ``` - `estimate` is the full price when the duration was known at acceptance, and `null` otherwise, for example for a URL input. - `max_charge` is the cap that applied, whether you set it or it was the default. - `total` is what was charged: `null` while the job is queued or running, the actual charge once it succeeds, and `"0.00"` when it fails or is cancelled. Every result carries an itemized `receipt`: ```json "receipt": { "currency": "USD", "total": "0.05", "items": [ { "item": "analysis", "amount": "0.04", "unit_price_per_minute": "0.08" }, { "item": "segment", "amount": "0.01", "unit_price_per_minute": "0.02" } ], "billable_seconds": 30, "price_version": "2026-11-01" } ``` Repeating a request with the same idempotency key returns the original job and charges nothing more. ## Analysis reuse When you send an identical segmentation request for identical media, recognized by the file's SHA-256 hash, Story0 can answer from your account's earlier analysis. The `analysis` line is then left out, so the result costs only the `segment` line: $0.02 per minute instead of $0.10. A reused result has `provenance.analysis_reused: true`. [Segmentation](/docs/segments) lists exactly what has to match. Screenings have no separate analysis line, so a reused screening costs the same $0.02 per minute as a new one. ## Test mode never bills Jobs created with a test key (`s0_test_…`) return fixtures and are never charged. Their `charge.total` and `receipt.total` are `"0.00"`, and they don't check your balance or your cap. See [Test mode](/docs/test-mode). ## Balance and usage ```ts tab="TypeScript" const balance = await story0.balance.retrieve(); const usage = await story0.usage.retrieve({ days: 30 }); ``` ```python tab="Python" balance = client.balance.retrieve() usage = client.usage.retrieve(days=30) ``` ```bash tab="cURL" curl https://api.story0.com/v1/balance -H "Authorization: Bearer $STORY0_API_KEY" curl "https://api.story0.com/v1/usage?days=30" -H "Authorization: Bearer $STORY0_API_KEY" ``` `GET /v1/balance` returns what you can spend now: ```json { "object": "balance", "currency": "USD", "available": "9.43", "held": "0.50", "credits": 943 } ``` `available` excludes holds for queued and running jobs, `held` is the sum of those holds, and `credits` is `available` expressed in credits. `GET /v1/usage` summarizes live charges over the last `days` days (1 to 90, default 30), by day and operation. It counts succeeded live jobs only; test-mode work is never billed and isn't included. ```json { "object": "usage", "currency": "USD", "period": { "start": "2026-10-04T15:02:11.000Z", "end": "2026-11-03T15:02:11.000Z" }, "totals": { "jobs": 14, "billable_seconds": 1840, "amount": "2.5173" }, "days": [ { "date": "2026-11-03", "operation": "screen", "jobs": 6, "billable_seconds": 412, "amount": "0.1373" }, { "date": "2026-11-03", "operation": "segment", "jobs": 8, "billable_seconds": 1428, "amount": "2.38" } ] } ``` ## A worked example Take the 24-second sample clip from the [Quickstart](/docs/quickstart), uploaded as a ready asset, on a new account with $10.00 of free credit: 1. **Estimate a segmentation.** 24 seconds rounds up to the 30-second minimum: `analysis` $0.04 plus `segment` $0.01, $0.05 in all. 2. **Submit it with `max_charge` $0.50.** The duration is known and $0.05 is under the cap, so Story0 accepts the job and holds $0.05. The balance shows `available` $9.95 and `held` $0.05. 3. **The job succeeds.** Story0 charges $0.05 (5 credits) and the hold closes: `available` $9.95, `held` $0.00. 4. **Screen the same clip.** 30 billable seconds at $0.02 per minute: $0.01. 5. **Send the same segmentation again with a new idempotency key.** The analysis is reused, so only the `segment` line applies: $0.01. That's $0.07, or 7 credits, for the whole session, leaving $9.93. Had you passed the clip's URL instead of the asset in step 2, Story0 would have held the full $0.50 until the job finished, then charged the same $0.05 and released $0.45. --- # Test mode > Build and test your integration against free, deterministic fixtures before you process real footage. Every account can create two kinds of API key. Live keys (`s0_live_…`) analyze your footage and are billed. Test keys (`s0_test_…`) go through the same endpoints, validation, jobs and webhooks, but return deterministic fixtures instead of analyzing anything, and they're never billed. Use them for CI, contract tests and wiring up a pipeline; use a live key whenever you want to see what Story0 makes of real footage. Create test keys on the Keys page of the [console](https://dev.story0.com/console/keys), next to your live keys. The key alone decides the mode: the base URL and code stay the same. ## What changes with a test key | | Live key | Test key | | --- | --- | --- | | Results | Analysis of your video | Fixed fixtures, marked `fixture: true` | | `livemode` on assets, jobs and results | `true` | `false` | | Charges | Held and settled in credits | None: `charge.total` and `receipt.total` are `"0.00"` | | Balance and `max_charge` checks | Applied | Skipped, so never `insufficient_credits` or `charge_cap_exceeded` | | `input.url` in a processing request | Fetched and read | Not fetched; the fixture assumes a 60-second video | | Uploads and `POST /v1/assets` | Stored and read | Stored and read in exactly the same way | | Analysis reuse | Applies | Never | | `GET /v1/usage` | Included | Excluded | ## Fixture results Fixtures depend only on the mode, your labels or categories, the range and the video's duration, so the same request always returns the same result. For a test asset that's `ready`, the duration is the file's real duration. For an `input.url`, or an asset whose media facts haven't been read yet, it's 60 seconds, so wait for uploads to be `ready` (as `uploadAndWait` does) if your tests depend on the duration. ### Partition Three equal intervals across the range: your first label, then the uncertainty label (with `review.required: true`), then your second label. Only the first interval has evidence. For a 24-second test asset: ```json { "object": "segmentation", "livemode": false, "fixture": true, "mode": "partition", "duration_seconds": 24, "range": { "start": 0, "end": 24 }, "coverage": { "timeline": "complete", "sampled_frames": 0, "frame_interval_seconds": 0.5, "uncertain_seconds": 8, "modalities": ["fixture"] }, "boundary_resolution_seconds": 0.5, "segments": [ { "id": "interval_1", "start": 0, "end": 8, "label": "dog_visible", "review": { "required": false, "reasons": [] }, "evidence": [{ "id": "ev_1", "kind": "seen", "start": 0, "end": 0.5, "description": "Fixture evidence (test mode)" }] }, { "id": "interval_2", "start": 8, "end": 16, "label": "uncertain", "review": { "required": true, "reasons": ["insufficient_evidence"] }, "evidence": [] }, { "id": "interval_3", "start": 16, "end": 24, "label": "no_dog_visible", "review": { "required": false, "reasons": [] }, "evidence": [] } ], "receipt": { "currency": "USD", "total": "0.00", "items": [], "billable_seconds": 0, "price_version": "2026-11-01" } } ``` The other result fields (`id`, `job_id`, `asset_id`, `config`, `provenance`, `retention` and so on) are present as usual; they're left out here for brevity. ### Extract One moment covering the middle third of the range, with `label: "match"`, `title: "Fixture moment"`, one evidence item at its start and `uncertain_seconds: 0`. ### Screening Your first category is a `match` (score 0.9, evidence from 1 to 2 seconds) and every other category is `no_match` (score 0.1). `matched` contains only the first key, and every observation reads "Fixture answer (test mode)." In every fixture, `coverage.modalities` is `["fixture"]` and `coverage.sampled_frames` is 0, so code can always tell a fixture from an analysis, even without checking `fixture`. ## What still works for real - **Validation.** Requests are checked exactly as in live mode, so `invalid_request` and the other request errors behave the same. - **Media.** Uploads are stored and read, test assets get real media facts, and an unreadable file still fails with `media_unreadable`. `POST /v1/assets` fetches the URL as in live mode. - **Jobs.** Test jobs move through `queued`, `running` and a final state, support `wait`, cancellation and listing, and emit `job.*` events to your webhook endpoints. - **Idempotency.** Keys work the same way. - **Limits.** Test requests count toward your rate limits and the 20 active jobs per account. - **Retention.** Test media and results expire on the same schedule as live ones. ## Keep test and live apart - **Assets belong to one mode.** A test key can't process a live asset, or the reverse; the request is refused with `invalid_request` on `/input/asset_id`. - **Lists show both modes.** `GET /v1/assets`, `GET /v1/jobs` and `GET /v1/events` return test and live objects together. Filter on `livemode`. - **Webhook endpoints receive both.** Check `data.job.livemode` or `data.asset.livemode` in your handler. - **Idempotency keys are shared.** Reusing a key across modes is a different request, so it returns `idempotency_key_reused`. - **There's one balance.** `GET /v1/balance` shows your account's real balance with either key, and estimates quote live prices with either key. ## Use test mode in CI Because fixtures are deterministic, you can assert on exact values. A URL input isn't fetched in test mode, so any well-formed URL works and the fixture assumes 60 seconds: ```ts tab="TypeScript" import assert from "node:assert/strict"; import Story0 from "@story0/sdk"; const story0 = new Story0({ apiKey: process.env.STORY0_TEST_KEY }); const result = await story0.segments.createAndWait({ input: { url: "https://media.example.com/any-clip.mp4" }, mode: "partition", instruction: "Partition the video by whether a dog is visible.", labels: [ { key: "dog_visible", description: "A dog is visible." }, { key: "no_dog_visible", description: "No dog is visible." }, ], }); assert.equal(result.fixture, true); assert.deepEqual(result.segments.map((s) => s.label), ["dog_visible", "uncertain", "no_dog_visible"]); assert.deepEqual(result.segments.map((s) => [s.start, s.end]), [[0, 20], [20, 40], [40, 60]]); assert.equal(result.receipt.total, "0.00"); ``` ```python tab="Python" import os from story0 import Story0 client = Story0(api_key=os.environ["STORY0_TEST_KEY"]) result = client.segments.create_and_wait( input={"url": "https://media.example.com/any-clip.mp4"}, mode="partition", instruction="Partition the video by whether a dog is visible.", labels=[ {"key": "dog_visible", "description": "A dog is visible."}, {"key": "no_dog_visible", "description": "No dog is visible."}, ], ) assert result.get("fixture") is True assert [s["label"] for s in result["segments"]] == ["dog_visible", "uncertain", "no_dog_visible"] assert [(s["start"], s["end"]) for s in result["segments"]] == [(0, 20), (20, 40), (40, 60)] assert result["receipt"]["total"] == "0.00" ``` Test mode is also the easy way to exercise your webhook handler end to end: run a test job and watch its `job.succeeded` arrive. Test mode can't exercise everything. It never returns `insufficient_credits` or `charge_cap_exceeded`, never fails on a URL it couldn't fetch, and always produces exactly one uncertain interval. Cover those paths with live requests on short clips, or with mocked responses in your own tests. ## Switch to live 1. Replace the test key with a live key in your environment. Nothing else in your code changes. 2. Make sure the account has credit: verify your email for the free $10, or buy credit on the Billing page. 3. Set `billing.max_charge` on processing requests. Test mode never checks it, so a cap that's too low only shows up in live mode. 4. Allow for real processing times. Live jobs take as long as the analysis needs, while fixtures finish quickly. 5. Ignore test events in production webhook handlers by checking `livemode`. 6. Expect `uncertain` intervals and `review.required` flags wherever your footage calls for them, rather than exactly one per result. --- # Errors > The problem document every failed request returns, every error code Story0 uses, and what to do about each one. Story0 reports errors in two places. A request that fails gets an HTTP error status and a problem document. A job that fails after it was accepted is still retrieved with `200 OK`, with `status: "failed"` and the reason in `job.error`. Both use the same stable `code` values, so you can branch on `code` everywhere. ## The problem document Every HTTP error has the content type `application/problem+json` and follows [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457.html): ```json { "type": "https://dev.story0.com/docs/errors#insufficient_credits", "title": "Not enough credits", "status": 402, "code": "insufficient_credits", "detail": "Add credits in the console, or lower `billing.max_charge`.", "request_id": "req_01m6gwhj03t8wq2c5n7z3k9r4a", "needed": "2.00", "available": "1.35", "currency": "USD" } ``` | Field | Description | | --- | --- | | `type` | A link to this page, at the section for the code. | | `title` | A short summary for people. | | `status` | The HTTP status, repeated in the body. | | `code` | The stable, machine-readable reason. Branch on this. | | `detail` | An explanation of this occurrence, when there is one. | | `request_id` | The same value as the `x-request-id` response header. Log it. | Some codes add extension members: `errors` for validation failures, `needed`, `available` and `currency` for `insufficient_credits`, and `estimate`, `max_charge` and `currency` for `charge_cap_exceeded`. Amounts are decimal strings in US dollars. `title` and `detail` are written for people and their wording can change, so don't parse them. Validation errors list every problem with a JSON pointer into your request: ```json { "type": "https://dev.story0.com/docs/errors#invalid_request", "title": "The request is not valid", "status": 422, "code": "invalid_request", "detail": "/labels/1/key: Use lowercase letters, digits and underscores, starting with a letter.", "request_id": "req_01m6gwk7t3c9v2n5q8x1r4m6d0", "errors": [ { "pointer": "/labels/1/key", "detail": "Use lowercase letters, digits and underscores, starting with a letter." } ] } ``` ## Job errors When a job fails, `job.error` has the same `code`, `title` and `detail`, plus any extension members: ```json "error": { "code": "charge_cap_exceeded", "title": "The work would cost more than `max_charge`", "detail": null, "estimate": "0.75", "max_charge": "0.50", "currency": "USD" } ``` Failed jobs are never charged. A failed job is final: to try again, send a new request with a new idempotency key. Assets that fail record the same kind of object in `asset.error`, and a job on such an asset fails with the asset's code. ## All codes | Code | HTTP status | Where it appears | Meaning | | --- | --- | --- | --- | | `unauthorized` | 401 | Response | The API key is missing, malformed, unknown or revoked. | | `forbidden` | 403 | Response | The account is suspended, or the endpoint is for the console only. | | `not_found` | 404 | Response | The object doesn't exist or isn't visible to this account. | | `route_not_found` | 404 | Response | The method and path aren't part of the API. | | `invalid_request` | 422 | Response | The request failed validation. See `errors`. | | `idempotency_key_reused` | 422 | Response | The key was already used for a different request. | | `unsupported_input` | 422 | Response, job, asset | The input's type, size, length or address isn't supported. | | `upload_missing` | 422 | Response, job, asset | The upload was completed before its bytes arrived. | | `asset_failed` | 422 | Response | The request referenced an asset whose processing failed. | | `source_unavailable` | 422 | Response, job, asset | A URL couldn't be resolved or fetched. | | `insufficient_credits` | 402 | Response | The available balance can't cover the hold. | | `charge_cap_exceeded` | 402 | Response, job | The work would cost more than `billing.max_charge`. | | `result_expired` | 410 | Response | The result has passed its 30-day retention. | | `rate_limited` | 429 | Response | Too many requests in a minute. `Retry-After` says when to try again. | | `concurrency_limit` | 429 | Response | 20 jobs are already queued or running. `Retry-After: 5`. | | `range_outside_media` | 422 | Job | The segmentation `range` has no video in it. | | `internal_error` | 500 | Response | Something went wrong on Story0's side. | | `billing_unavailable` | 502, 503 | Console | Credit purchases are temporarily unavailable. | | `media_unreadable` | None | Job, asset | The file isn't a readable video. | | `asset_deleted` | None | Job | The asset's media was deleted before the job read it. | | `capacity_exhausted` | None | Job | Processing capacity for the day is used up. | | `model_unavailable` | None | Job | Analysis is temporarily unavailable. | | `model_failed` | None | Job | The analysis didn't produce a usable answer. | | `processing_failed` | None | Job | Processing failed and retries didn't help. | ## Request errors ### unauthorized **401.** The `Authorization` header is missing, isn't in the form `Bearer `, or the key is unknown or revoked. Check that `STORY0_API_KEY` is set and current. If the key was revoked or lost, create a new one in the [console](https://dev.story0.com/console/keys). ### forbidden **403.** The key is valid but the request isn't allowed: the account is suspended, or an API key was sent to an endpoint that only the console can use. Don't retry. ### not_found **404.** The ID doesn't exist, belongs to another account, or refers to something no longer available, such as a deleted or expired asset. A result ID sent to the wrong endpoint, such as a screening ID to `/v1/segments/{id}`, is also `not_found`. Check the ID and that you're using a key from the right account. ### route_not_found **404.** No endpoint matches the method and path. Check for typos and the `/v1` prefix; the [API reference](/docs/api) lists every route. ### invalid_request **422.** The body, query string or headers failed validation. `errors` lists each problem with a JSON `pointer` and a `detail`, and the top-level `detail` repeats the first one. Common causes: - a missing required field, or a value outside its allowed range; - a label or category key that isn't lowercase letters, digits and underscores starting with a letter, or duplicate keys; - `partition` with fewer than two labels, `extract` with labels, or a label key equal to the uncertainty label; - both or neither of `input.asset_id` and `input.url`; - `range.end` not after `range.start`, or `min_seconds` greater than `max_seconds`; - a currency other than `USD` in `billing.max_charge`; - a test key with a live asset, or the reverse; - an `Idempotency-Key` with characters outside `A–Z a–z 0–9 _ . : -`, or a malformed `cursor`; - `wait` above 30 on `GET /v1/jobs/{id}`; - an estimate for an asset that isn't `ready` yet; - a webhook URL that doesn't use `https`, or an eleventh enabled webhook endpoint. Fix the request and send it again. Nothing was stored, so the same idempotency key still works. ### idempotency_key_reused **422.** You sent an `Idempotency-Key` that was already used, within the last 7 days, for a different request. Nothing was created. Use a new key for the new request. See [Idempotency and retries](/docs/idempotency) for what counts as the same request. ### unsupported_input **422.** The input is well-formed but not something Story0 can process. On a request it means an upload content type other than `video/*` or `application/octet-stream`, an upload over 2 GB, or a webhook URL that points at a private address, has credentials in it, or isn't `http` or `https` at all. As a job or asset error it means a video longer than 20 minutes, a file over 2 GB, a platform page (such as a YouTube link) or a web page instead of a video file, a private or local address, a URL that isn't `http` or `https`, or a URL with credentials. Change the input; retrying the same one won't help. ### upload_missing **422.** You completed an upload before all of its bytes arrived, or the uploaded file wasn't found when a job needed it. Finish sending the bytes to `upload_url`, then complete the upload again. See [Uploading media](/docs/uploads). ### asset_failed **422.** The `input.asset_id` refers to an asset whose processing failed. `detail` repeats the asset's own error, and `GET /v1/assets/{id}` shows its `error.code`. Fix the source file and create a new asset. ### source_unavailable **422.** A URL couldn't be used: the host didn't resolve, the server answered with an error status, it redirected more than 5 times, or the download didn't finish within 10 minutes. On `POST /v1/webhook_endpoints` it means the webhook host didn't resolve. As a job or asset error it means an input URL couldn't be fetched. Check that the URL is a direct, publicly reachable link to the file, or upload the file instead. ### insufficient_credits **402.** Your available balance is less than the hold this request needs. `needed` and `available` give both amounts. Buy credits on the [Billing page](https://dev.story0.com/console/billing), wait for running jobs to release their holds, or lower `billing.max_charge`; for URL inputs without a cap the hold is $2.00. Nothing was created, so the same idempotency key still works. ### charge_cap_exceeded **402.** The work would cost more than `billing.max_charge`. `estimate` is the full price and `max_charge` is your cap. On a request, nothing was created; raise the cap or don't process this video. As a job error, the video's duration only became known during the job; the job failed before any analysis and nothing was charged. See [Pricing and billing](/docs/billing). ### result_expired **410.** The result has passed its 30-day retention, and its content has been deleted; the job record is still there. To get a new result, run the request again with a new idempotency key. Media is kept for only 7 days, so upload it again first. ### rate_limited **429.** You sent more than 1,200 reads or 300 writes in a minute. The response carries `Retry-After` with the seconds until the window resets; wait that long and retry with the same idempotency key. The SDKs do this for you. ### concurrency_limit **429.** 20 of your jobs are already queued or running. Wait for some to finish (`Retry-After: 5`), then submit the next one. This limit protects your balance from a runaway loop as much as it protects the service. ### range_outside_media **422, on the job.** The segmentation's `range` contains less than a quarter of a second of video, usually because `range.start` is past the end. Nothing was charged. Check the asset's `media.duration_seconds` and send a range inside it. ### internal_error **500.** Something unexpected went wrong on Story0's side. Retry with backoff and the same idempotency key; nothing is charged twice. If it keeps happening, note the `request_id`. ### billing_unavailable **502 or 503.** Buying credits in the console is temporarily unavailable. Processing isn't affected. Try the purchase again later. ## Job errors by code The codes in this section appear only in `job.error` and `asset.error`, never as an HTTP status. `unsupported_input`, `upload_missing`, `source_unavailable` and `charge_cap_exceeded` can also appear there, as described above. ### media_unreadable The file couldn't be read as a video: it has no video stream, or no measurable duration. If the file plays elsewhere, re-encode it to a common format such as H.264 in an MP4 container, upload it again, and send a new request. ### asset_deleted The asset's media was deleted with `DELETE /v1/assets/{id}` after the job was accepted but before the job read it. Nothing was charged. Upload the media again and send a new request. ### capacity_exhausted Story0's processing capacity for the day was used up. Nothing was charged. Send the request again later with a new idempotency key. ### model_unavailable Analysis was temporarily unavailable when the job ran. Nothing was charged. Send the request again later with a new idempotency key. ### model_failed The analysis didn't produce a usable answer, even after Story0 retried it. The error carries `retryable: true`. Nothing was charged. Send the request again with a new idempotency key; if the same video fails repeatedly, simplify the instruction or the labels. ### processing_failed Processing failed for an unexpected reason, and Story0's automatic retries didn't help. Nothing was charged. Check that the media and any `range` are valid, for example that `range.start` lies inside the video, then send the request again with a new idempotency key. ## Retrying | Situation | What to do | | --- | --- | | Network error, `429`, `5xx` | Retry with backoff and the same idempotency key. The SDKs do this automatically. | | `402`, `422`, `401`, `403`, `404` | Fix the cause first. Retrying the same request gives the same answer. | | Job failed with a temporary cause (`capacity_exhausted`, `model_unavailable`, `model_failed`, `processing_failed`) | Send a new request, with a new idempotency key, later. | | Job failed because of the input (`media_unreadable`, `unsupported_input`, `source_unavailable`, `asset_deleted`, `charge_cap_exceeded`) | Fix the input or the cap, then send a new request with a new key. | ## SDK error classes The SDKs turn problem documents into typed errors. Every error has `status` (0 when no response arrived), `code`, the request ID (`requestId` in TypeScript, `request_id` in Python), `detail`, and `raw`, the full problem document. | Situation | TypeScript | Python | | --- | --- | --- | | Base class of every SDK error | `Story0Error` | `Story0Error` | | 401 | `AuthenticationError` | `AuthenticationError` | | 403 | `PermissionError` | `PermissionDeniedError` | | 404 or 410 | `NotFoundError` | `NotFoundError` | | 400 or 422 | `InvalidRequestError`, with `errors` | `InvalidRequestError`, with `errors` | | 402 `insufficient_credits` | `InsufficientCreditsError` | `InsufficientCreditsError` | | 402 `charge_cap_exceeded` | `ChargeCapExceededError` | `ChargeCapExceededError` | | 429 | `RateLimitError` | `RateLimitError` | | 5xx | `APIError` | `APIError` | | No response: network failure or timeout | `ConnectionError` | `APIConnectionError` | | A waited-for job failed | `JobFailedError`, with `job` | `JobFailedError`, with `job` | | A waited-for job was cancelled | `JobCancelledError`, with `job` | `JobCancelledError`, with `job` | | A wait ran out of time | `WaitTimeoutError`, with `jobId` | `WaitTimeoutError`, with `job_id` | | A webhook failed verification | `WebhookVerificationError` | `WebhookVerificationError` | For `JobFailedError`, `code` is the job's error code, such as `media_unreadable`. ```ts tab="TypeScript" import Story0, { ChargeCapExceededError, InsufficientCreditsError, InvalidRequestError, JobFailedError, } from "@story0/sdk"; const story0 = new Story0(); try { const result = await story0.segments.createAndWait(params); console.log(result.segments.length); } catch (err) { if (err instanceof ChargeCapExceededError) { console.log(`Would cost ${err.raw?.estimate}; the cap is ${err.raw?.max_charge}`); } else if (err instanceof InsufficientCreditsError) { console.log(`Needs ${err.raw?.needed}; ${err.raw?.available} available`); } else if (err instanceof InvalidRequestError) { for (const e of err.errors) console.log(e.pointer, e.detail); } else if (err instanceof JobFailedError) { console.log(`Job ${err.job.id} failed: ${err.code}`); } else { throw err; } } ``` ```python tab="Python" import story0 from story0 import Story0 client = Story0() try: result = client.segments.create_and_wait(**params) print(len(result["segments"])) except story0.ChargeCapExceededError as err: print(f"Would cost {err.raw['estimate']}; the cap is {err.raw['max_charge']}") except story0.InsufficientCreditsError as err: print(f"Needs {err.raw['needed']}; {err.raw['available']} available") except story0.InvalidRequestError as err: for e in err.errors: print(e["pointer"], e["detail"]) except story0.JobFailedError as err: print(f"Job {err.job['id']} failed: {err.code}") ``` --- # Limits and retention > Media sizes, request rates, field limits, supported inputs, and how long Story0 keeps each kind of data. This page collects every limit in one place, with what happens when you reach it. ## Media | Limit | Value | What happens past it | | --- | --- | --- | | Video length | 20 minutes (1,200 seconds) | The asset, or the job reading it, fails with `unsupported_input`. | | File size | 2 GB (2,147,483,648 bytes) | `POST /v1/uploads` with a larger `bytes`, completing a larger upload, or fetching a larger URL fails with `unsupported_input`. | | Minimum billable length | 30 seconds | Shorter videos are billed as 30 seconds. | | URL download time | 10 minutes | The fetch fails with `source_unavailable`. Upload the file instead. | | URL redirects | 5 | The fetch fails with `source_unavailable`. | | Upload chunk size | Multiples of 256 KiB (262,144 bytes), except the last chunk | Use other sizes only for the final chunk. The SDKs round chunk sizes for you. | | Upload session lifetime | 24 hours | Open a new session. | ## Supported inputs - **Video files** in common container formats and codecs, as long as the file has a video stream and a measurable duration. Story0 identifies the format from the file itself, whatever its extension or declared content type. A file it can't read fails with `media_unreadable`; if it plays elsewhere, re-encoding it to H.264 in an MP4 container usually helps. - **Upload content types:** any `video/*` type or `application/octet-stream`. - **URLs:** direct `http` or `https` links to a video file on a public address, without credentials in the URL. Platform pages (YouTube, Instagram and TikTok links, for example) and web pages aren't fetched. See [Uploading media](/docs/uploads). - **Audio** is detected and reported in `media.has_audio`, but it isn't analyzed: results are based on what's visible in sampled frames. ## Requests | Limit | Value | | --- | --- | | Read requests (`GET`) | 1,200 a minute per account | | Write requests (`POST`, `PUT`, `DELETE`) | 300 a minute per account | | Active jobs | 20 queued or running per account, test and live combined | | Long-poll `wait` on `GET /v1/jobs/{id}` | 0 to 30 seconds | | Page size (`limit`) for assets, jobs and events | 1 to 100, default 20 | | Webhook deliveries listed per endpoint | The 50 most recent | | `GET /v1/usage` window (`days`) | 1 to 90, default 30 | | Enabled webhook endpoints | 10 per account | | Active API keys | 25 per account | Rate limits are counted in one-minute windows and tracked by each API server separately, so treat them as approximate. Going over either one returns `429 rate_limited` with a `Retry-After` header giving the seconds until the window resets; the SDKs wait that long before retrying. The active-job limit returns `429 concurrency_limit` with `Retry-After: 5`. It counts jobs that are `queued` or `running`, so the way past it is to let jobs finish before you submit more. ## Request fields | Field | Limit | | --- | --- | | `instruction` | 3 to 2,000 characters | | `labels` (`partition`) | 2 to 12 | | `categories` (screening) | 1 to 20 | | Label and category `key`, `uncertainty.label` | 1 to 40 characters: a lowercase letter, then lowercase letters, digits or underscores | | Label and category `description` | 1 to 500 characters | | `sampling.fps` | 0.1 to 4 | | Frames inspected per segmentation | At most 600 | | `range.start` | 0 or more, and inside the video | | `range.end` | More than 0 and more than `range.start`; clamped to the video's duration | | `constraints.min_seconds` | More than 0, up to 600 | | `constraints.max_seconds` | More than 0, up to 3,600 | | `constraints.max_segments` | 1 to 500 | | `metadata` | Up to 20 pairs of strings, each value up to 500 characters | | `billing.max_charge.amount` | A decimal string with up to 6 decimal places; `currency` must be `USD` | | `Idempotency-Key` header | 1 to 255 characters from `A–Z a–z 0–9 _ . : -` | | `X-Request-Id` header | 8 to 80 characters from `A–Z a–z 0–9 _ -` | | Upload `filename` | Up to 255 characters | | Upload `content_type` | Up to 100 characters | | Webhook endpoint `description` | Up to 200 characters | | Feedback `comment`, `actor`, `label` | Up to 2,000, 200 and 40 characters | A request that breaks one of these limits is refused with `422 invalid_request`, and the `errors` array points at the field. The 600-frame limit is the exception: it isn't validated up front, so choose `sampling.fps` so that the rate multiplied by the range length stays at or below 600. See [Segmentation](/docs/segments). ## Retention | What | Kept for | Afterwards | | --- | --- | --- | | Asset media | 7 days from the asset's creation, or until a queued or running job that needs it finishes | The media is deleted and the asset returns `404 not_found`. | | Results | 30 days from creation | `GET` returns `410 result_expired`. The job record remains. | | Idempotency keys | 7 days | The same key starts a new job. | | Upload sessions | 24 hours | Open a new session. | | Estimates | Meant to be current for 1 hour (`expires_at`) | Request a new estimate. | | Webhook deliveries | Up to 18 attempts over about 17 hours | The delivery is marked `failed`; the event stays in `GET /v1/events`. | Every asset and result states its own expiry in `retention`, so you never need to compute it. You can delete media sooner with `DELETE /v1/assets/{id}`; results stay available until their own expiry. ## Timeouts and defaults | Item | Value | | --- | --- | | Hold for a request whose duration isn't known yet, without `max_charge` | $2.00 | | Time your webhook endpoint has to answer | 15 seconds | | Webhook signature tolerance in the SDK helpers | 5 minutes | | Automatic job retries before a job fails | Up to 5 attempts | | SDK network timeout per request | 60 seconds | | SDK automatic retries | 2 | | SDK wait for a job | 10 minutes | | SDK wait for an asset to become ready | 5 minutes | --- # SDKs > Install and configure the TypeScript and Python libraries, and find every method, helper and error class they provide. Story0 has official SDKs for TypeScript and JavaScript (`@story0/sdk`) and for Python (`story0`). Both cover the whole API, add helpers for resumable uploads, waiting and webhook verification, retry safely with idempotency keys, and raise typed errors. They follow the same design, so a method you know in one has the same name in the other: camelCase in TypeScript, snake_case in Python. ## Install ```bash tab="TypeScript" npm install https://dev.story0.com/sdk/story0-sdk-0.1.0.tgz ``` ```bash tab="Python" pip install https://dev.story0.com/sdk/story0-0.1.0-py3-none-any.whl ``` | | TypeScript | Python | | --- | --- | --- | | Package | `@story0/sdk` | `story0` | | Version | 0.1.0 | 0.1.0 | | Runtime | Node.js 18 or later; other runtimes with `fetch` and Web Crypto work, except for uploading by file path | Python 3.9 or later | | Modules | ES modules and CommonJS | Synchronous `Story0` and asynchronous `AsyncStory0` clients | | Return values | Plain objects, typed from the OpenAPI document | Plain `dict`s, described by `TypedDict` classes in `story0.types` | ## Create a client ```ts tab="TypeScript" import Story0 from "@story0/sdk"; const story0 = new Story0({ apiKey: process.env.STORY0_API_KEY, // the default timeoutMs: 30_000, maxRetries: 3, }); ``` ```python tab="Python" import os from story0 import Story0 client = Story0( api_key=os.environ["STORY0_API_KEY"], # the default timeout=30, max_retries=3, ) ``` | TypeScript | Python | Default | Description | | --- | --- | --- | --- | | `apiKey` | `api_key` | The `STORY0_API_KEY` environment variable | Your API key. A live or test key decides the mode. | | `baseUrl` | `base_url` | `STORY0_BASE_URL`, then `https://api.story0.com` | The API's base URL. | | `timeoutMs` | `timeout` | 60 seconds (`60000` ms; `60.0` s) | The network timeout for each request. | | `maxRetries` | `max_retries` | 2 | Automatic retries for network errors, `429` and `5xx`. | | `fetch` | `http_client` | The global `fetch`; a new `httpx.Client` | A custom transport, for proxies, custom TLS or tests. | Creating a client without a key raises an error with the code `missing_api_key`. Both clients send `Authorization`, `Story0-Version: 2026-11-01` and an SDK identifier with every request. The Python client can be used as a context manager (`with Story0() as client:`) so its HTTP connections are closed when you're done; `client.close()` does the same. `AsyncStory0` takes the same arguments, with an `httpx.AsyncClient` for `http_client`, and every method is a coroutine: ```python import asyncio from story0 import AsyncStory0 async def main() -> None: async with AsyncStory0() as client: asset = await client.assets.upload_and_wait("clip.mp4") result = await client.segments.create_and_wait( input={"asset_id": asset["id"]}, mode="extract", instruction="Moments where a dog is visible", ) print(len(result["segments"])) asyncio.run(main()) ``` ## Methods | Task | TypeScript | Python | HTTP | | --- | --- | --- | --- | | Upload a file | `assets.upload({ file })` | `assets.upload(file)` | `POST /v1/uploads`, the bytes, `POST /v1/uploads/{id}/complete` | | Upload and wait until ready | `assets.uploadAndWait({ file })` | `assets.upload_and_wait(file)` | The above, then `GET /v1/assets/{id}` | | Open an upload session | `uploads.create()` | `uploads.create()` | `POST /v1/uploads` | | Complete an upload | `uploads.complete(id)` | `uploads.complete(id)` | `POST /v1/uploads/{id}/complete` | | Create an asset from a URL | `assets.create({ url })` | `assets.create(url)` | `POST /v1/assets` | | Retrieve an asset | `assets.retrieve(id)` | `assets.retrieve(id)` | `GET /v1/assets/{id}` | | List assets | `assets.list({ limit, cursor })` | `assets.list(limit=, cursor=)` | `GET /v1/assets` | | Delete an asset's media | `assets.delete(id)` | `assets.delete(id)` | `DELETE /v1/assets/{id}` | | Wait for an asset | `assets.wait(id)` | `assets.wait(id)` | Polls `GET /v1/assets/{id}` | | Request a segmentation | `segments.create(params)` | `segments.create(**params)` | `POST /v1/segments` | | Segment and wait for the result | `segments.createAndWait(params)` | `segments.create_and_wait(**params)` | The above, a wait, then `GET /v1/segments/{id}` | | Retrieve a segmentation | `segments.retrieve(id)` | `segments.retrieve(id)` | `GET /v1/segments/{id}` | | Request a screening | `screenings.create(params)` | `screenings.create(**params)` | `POST /v1/screenings` | | Screen and wait for the result | `screenings.createAndWait(params)` | `screenings.create_and_wait(**params)` | The above, a wait, then `GET /v1/screenings/{id}` | | Retrieve a screening | `screenings.retrieve(id)` | `screenings.retrieve(id)` | `GET /v1/screenings/{id}` | | Retrieve a job | `jobs.retrieve(id, { wait })` | `jobs.retrieve(id, wait=)` | `GET /v1/jobs/{id}` | | List jobs | `jobs.list({ status, limit, cursor })` | `jobs.list(status=, limit=, cursor=)` | `GET /v1/jobs` | | Wait for a job | `jobs.wait(id)` | `jobs.wait(id)` | Long-polls `GET /v1/jobs/{id}` | | Cancel a job | `jobs.cancel(id)` | `jobs.cancel(id)` | `POST /v1/jobs/{id}/cancel` | | Estimate a charge | `estimates.create({ operation, input })` | `estimates.create(operation=, input=)` | `POST /v1/estimates` | | Retrieve the balance | `balance.retrieve()` | `balance.retrieve()` | `GET /v1/balance` | | Summarize usage | `usage.retrieve({ days })` | `usage.retrieve(days)` | `GET /v1/usage` | | Create a webhook endpoint | `webhookEndpoints.create({ url, events })` | `webhook_endpoints.create(url, events=)` | `POST /v1/webhook_endpoints` | | List webhook endpoints | `webhookEndpoints.list()` | `webhook_endpoints.list()` | `GET /v1/webhook_endpoints` | | Disable a webhook endpoint | `webhookEndpoints.delete(id)` | `webhook_endpoints.delete(id)` | `DELETE /v1/webhook_endpoints/{id}` | | Send a test event | `webhookEndpoints.test(id)` | `webhook_endpoints.test(id)` | `POST /v1/webhook_endpoints/{id}/test` | | List recent deliveries | `webhookEndpoints.deliveries(id)` | `webhook_endpoints.deliveries(id)` | `GET /v1/webhook_endpoints/{id}/deliveries` | | List events | `events.list({ type, limit, cursor })` | `events.list(type=, limit=, cursor=)` | `GET /v1/events` | | Record reviewer feedback | `feedback.create(params)` | `feedback.create(**params)` | `POST /v1/feedback` | | Verify a webhook delivery | `Story0.webhooks.verify(body, headers, secret)` | `story0.verify_webhook(body, headers, secret)` | None: runs locally | In TypeScript, request parameters are a plain object matching the JSON body, and per-call options go in a second argument: `idempotencyKey`, `signal` (an `AbortSignal`) and `timeoutMs`, plus `waitTimeoutMs`, `pollIntervalMs` and `onProgress` on the waiting helpers. In Python, everything is a keyword argument: the body fields, plus `idempotency_key`, `timeout`, and `wait_timeout`, `poll_interval` and `on_progress` on the waiting helpers. Python's create methods also accept `extra_body`, a dictionary merged into the JSON body, for fields added to the API after your SDK version. ## Uploads `assets.upload` and `assets.uploadAndWait` open an upload session, send the bytes and complete the upload, so you never handle the protocol yourself. | | TypeScript | Python | | --- | --- | --- | | Sources | A file path (Node), `Blob` or `File`, `ArrayBuffer`, `Uint8Array`, `ReadableStream`, or an async iterable of `Uint8Array` | A path (`str` or `pathlib.Path`), `bytes`, a binary file object, or an iterable of `bytes` | | Options | `filename`, `contentType` (default `video/mp4`), `chunkSize`, `onProgress(sent, total)` | `filename`, `content_type` (default `video/mp4`), `chunk_size`, `on_progress(sent, total)` | Files are sent in 8 MiB chunks by default, rounded down to a multiple of 256 KiB, and no more than a chunk or two is held in memory. Each chunk is tried up to four times. For a source that can be read again, such as a path, a buffer or a seekable file, a retry first asks the session how much it stored and resumes from there; a one-shot stream retries the chunk it holds. Upload requests go to the session's own URL with only the session's headers: the SDKs never send your API key there. For the protocol itself, see [Uploading media](/docs/uploads). ## Waiting | Helper | Returns | Raises | Default limit | | --- | --- | --- | --- | | `segments.createAndWait`, `screenings.createAndWait` (`create_and_wait`) | The segmentation or screening | `JobFailedError`, `JobCancelledError`, `WaitTimeoutError` | 10 minutes | | `jobs.wait(id)` | The job in its final state, whatever that is | `WaitTimeoutError` | 10 minutes | | `assets.uploadAndWait`, `assets.wait(id)` (`upload_and_wait`) | The `ready` asset | A `Story0Error` with the asset's error code if it failed, or `wait_timeout` | 5 minutes | The job helpers long-poll, holding each request for up to 20 seconds, or 2 seconds when you pass a progress callback so that it sees every stage. They never hold one connection open for the whole wait. Change the limit with `waitTimeoutMs` in TypeScript or `wait_timeout` (in seconds) in Python. Stopping a wait stops only your local waiting: aborting a TypeScript `signal`, or the wait timing out, leaves the job running. `WaitTimeoutError` carries the job's ID (`jobId`, `job_id`), so you can resume with `jobs.wait` instead of submitting again. To stop the work itself, call `jobs.cancel`. See [Jobs and results](/docs/jobs). ## Retries and idempotency The SDKs retry network errors, `429` and `5xx` responses up to `maxRetries` (`max_retries`) times, with exponential backoff and jitter (about 0.5, 1 and 2 seconds, up to 8), and they honor a `Retry-After` header of up to 20 seconds. They only retry reads and requests that carry an idempotency key. Processing requests, upload sessions and completions, asset and webhook-endpoint creation, cancellations and test events all get an idempotency key automatically, a random UUID unless you pass `idempotencyKey` or `idempotency_key`, and the key stays the same across that call's retries. Feedback is sent with a key, and retried, only when you pass one; the API then records it once. Pass your own keys for processing requests whenever a retry might come from a new process. See [Idempotency and retries](/docs/idempotency). ## Errors Every error the SDKs raise extends `Story0Error` and carries `status` (0 when no response arrived), `code`, the request ID (`requestId`, `request_id`), `detail` and `raw`, the full problem document. Branch on the class or on `code`, never on the message. | Situation | TypeScript | Python | | --- | --- | --- | | 401 | `AuthenticationError` | `AuthenticationError` | | 403 | `PermissionError` | `PermissionDeniedError` | | 404 or 410 | `NotFoundError` | `NotFoundError` | | 400 or 422 | `InvalidRequestError`, with `errors` | `InvalidRequestError`, with `errors` | | 402 `insufficient_credits` | `InsufficientCreditsError` | `InsufficientCreditsError` | | 402 `charge_cap_exceeded` | `ChargeCapExceededError` | `ChargeCapExceededError` | | 429 | `RateLimitError` | `RateLimitError` | | 5xx | `APIError` | `APIError` | | No response | `ConnectionError` | `APIConnectionError` | | A waited-for job failed | `JobFailedError`, with `job` | `JobFailedError`, with `job` | | A waited-for job was cancelled | `JobCancelledError`, with `job` | `JobCancelledError`, with `job` | | A job wait ran out of time | `WaitTimeoutError`, with `jobId` | `WaitTimeoutError`, with `job_id` | | A webhook failed verification | `WebhookVerificationError` | `WebhookVerificationError` | API error codes are listed in [Errors](/docs/errors). These codes come from the SDKs themselves: | Code | Raised when | | --- | --- | | `missing_api_key` | No key was passed and `STORY0_API_KEY` isn't set. | | `timeout`, `connection_error` | No response arrived (`ConnectionError` or `APIConnectionError`). | | `upload_failed` | The upload session refused a chunk. | | `upload_connection_failed` | The network failed while sending upload bytes. | | `wait_timeout` | A waiting helper ran out of time. | | `job_cancelled` | The job a `createAndWait` call was waiting for was cancelled. | | `result_missing` | A job succeeded without a result reference. This shouldn't happen; report the job ID if it does. | | `webhook_headers_missing`, `webhook_timestamp_invalid`, `webhook_signature_invalid` | Webhook verification failed. | ## Webhook verification ```ts tab="TypeScript" import Story0 from "@story0/sdk"; const event = await Story0.webhooks.verify(rawBody, request.headers, process.env.STORY0_WEBHOOK_SECRET!); ``` ```python tab="Python" import os import story0 event = story0.verify_webhook(raw_body, request.headers, os.environ["STORY0_WEBHOOK_SECRET"]) ``` Pass the raw body exactly as it arrived, the request headers and the endpoint's `whsec_…` secret. Both helpers check the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers, reject timestamps more than 5 minutes from now (change it with `{ toleranceSeconds }` or `tolerance=`), and return the event as `{ id, type, timestamp, data }`. TypeScript also exports the helper as `verifyWebhook`, and it's available on a client instance as `story0.webhooks.verify`. Python also offers `Story0.webhooks.verify`, and `story0.sign_webhook(secret, msg_id, timestamp, payload)` for building signed test deliveries for your own handler. See [Webhooks](/docs/webhooks). ## Types The TypeScript package exports types for every object, including `Asset`, `Upload`, `Job`, `JobStatus`, `Segmentation`, `Segment`, `Screening`, `ScreeningCategory`, `Estimate`, `Balance`, `Usage`, `WebhookEndpoint`, `WebhookDelivery`, `Event`, `WebhookEvent`, `Problem`, `SegmentCreateParams` and `ScreenCreateParams`. They're generated from the OpenAPI document. The Python package describes the same shapes as `TypedDict` classes in `story0.types`, for editors and type checkers; at run time every value is a plain `dict` or `list`. Within an API version, fields can be added, so write code that tolerates keys it doesn't know. ## Keep keys on the server The SDKs are for servers, scripts, workers and command-line tools. Never ship an API key to a browser or a mobile app: anyone who has it can spend your credits and read your results. Keep it in an environment variable or a secret manager, and pass results to your front end through your own API. ---