Story0.API

Concepts

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

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