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
{
"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
BloborFile, anArrayBufferorUint8Array, aReadableStream, or an async iterable ofUint8Arraychunks. - Python: a path (
strorpathlib.Path),bytes, a binary file object opened with"rb", or an iterable ofbytes.
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
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:
{
"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:
curl -X PUT "$UPLOAD_URL" -H "Content-Type: video/quicktime" --upload-file raw-0412.movA 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):
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/1.1 308
Range: bytes=0-8388607308 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):
curl -i -X PUT "$UPLOAD_URL" -H "Content-Range: bytes */734003200" -H "Content-Length: 0"308withRange: bytes=0-N: the first N + 1 bytes are stored. Continue from byte N + 1.308with noRangeheader: nothing is stored yet. Start from byte 0.2xx: the upload is already complete.
4. Complete the upload
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:
httporhttpsonly, 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
localhostor*.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, eachPOST /v1/assetscall 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:
{
"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 withcharge_cap_exceededbefore any analysis, and nothing is charged. - Each request fetches again. Every request with a URL input creates a new asset. The job's
asset_ididentifies 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:
- Your server calls
POST /v1/uploadswith its key and sends only the returnedupload_urlandheadersto the page. - The page
PUTs the file (orContent-Rangechunks) toupload_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. - 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 }) });