Story0.API

Get started

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 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) 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.

2. Install an SDK

npm install https://dev.story0.com/sdk/story0-sdk-0.1.0.tgz
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

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`);
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.

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}` },
);
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.

5. Print the intervals

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`);
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.

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 `<button title="${s.label}: ${s.start}–${s.end} s"
    onclick="document.getElementById('player').currentTime = ${s.start}"
    style="position:absolute; top:0; bottom:0; left:${left}%; width:${width}%; border:0; background:${colors.get(s.label)}"></button>`;
});

writeFileSync(
  "timeline.html",
  `<video id="player" src="park-dog-demo.mp4" controls style="width:640px"></video>
<div style="position:relative; width:640px; height:24px">${bars.join("")}</div>`,
);
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'<button title="{s["label"]}: {s["start"]}–{s["end"]} s" '
        f'onclick="document.getElementById(\'player\').currentTime = {s["start"]}" '
        f'style="position:absolute; top:0; bottom:0; left:{left}%; width:{width}%; '
        f'border:0; background:{colors[s["label"]]}"></button>'
    )

with open("timeline.html", "w", encoding="utf-8") as f:
    f.write(
        '<video id="player" src="park-dog-demo.mp4" controls style="width:640px"></video>\n'
        f'<div style="position:relative; width:640px; height:24px">{"".join(bars)}</div>'
    )

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 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:

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" } },
});
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.

Next steps

  • Segmentation: extract mode, ranges, sampling, constraints and how to write good labels.
  • Screening: quick triage across up to 20 categories for $0.02 a minute.
  • Webhooks: receive job.succeeded instead of waiting in your process.
  • Jobs and results: the lifecycle, cancellation and recovering after a disconnect.
  • Idempotency and retries: choosing keys that survive restarts.