Concepts
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
{
"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/1.1 202 Accepted
Content-Type: application/json
Location: /v1/jobs/job_01m6gwhj05rk4v9c2x7m3d8f6p
Retry-After: 2The 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.
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.
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
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),
});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 raisesJobFailedErrorif the job fails,JobCancelledErrorif it's cancelled, andWaitTimeoutErrorif 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, onlyWaitTimeoutErrorwhen 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.
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):
{
"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 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
const job = await story0.jobs.cancel("job_01m6gwhj05rk4v9c2x7m3d8f6p");job = client.jobs.cancel("job_01m6gwhj05rk4v9c2x7m3d8f6p")curl -X POST https://api.story0.com/v1/jobs/job_01m6gwhj05rk4v9c2x7m3d8f6p/cancel \
-H "Authorization: Bearer $STORY0_API_KEY"- A
queuedjob is cancelled at once and costs nothing. - A
runningjob 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 showsrunning, so wait for the final state:cancelled(not charged), orsucceeded(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.
const running = await story0.jobs.list({ status: "running", limit: 100 });
for (const job of running.data) console.log(job.id, job.stage, job.progress);running = client.jobs.list(status="running", limit=100)
for job in running["data"]:
print(job["id"], job["stage"], job["progress"])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-pollGET /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:
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);
}
}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"])