Story0.API

Guides

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.

Upload with an SDK

const asset = await story0.assets.uploadAndWait({
  file: "./interviews/raw-0412.mov",
  contentType: "video/quicktime",
  onProgress: (sent, total) => console.log(`${sent} of ${total ?? "?"} bytes`),
});
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 */<total> (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.

Create an asset from a URL

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);
created = client.assets.create("https://media.example.com/feeds/2026-11-03/clip-118.mp4")
asset = client.assets.wait(created["id"])
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.

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.

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:

await story0.assets.delete("asset_01m6gwgx34hq7d2m9ycv5k8b1e");
client.assets.delete("asset_01m6gwgx34hq7d2m9ycv5k8b1e")
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.

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);
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"]
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 PUTs 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.
// 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 }) });