One TypeScript job shape for Sume /v1/videos and /v1/jobs responses
Sume's /v1/videos returns a bare object and /v1/jobs wraps in data. A short normalizer maps both to one state, including the cancelled versus canceled spelling.

Write one normalize(payload) function that detects which route produced the payload. A /v1/jobs/{id}/status response nests under data and carries sume_status; a /v1/videos/{id} response is a bare object with status and polling_url. Map both to a single {id, state, terminal} and the rest of your code never branches on route.
The two vocabularies
The two routes spell states differently. Note cancelled with two Ls on the videos route versus canceled on the jobs route; a typo there leaves cancelled jobs looking non-terminal forever.
| Our state | `/v1/jobs` sume_status | `/v1/videos` status |
|---|---|---|
| queued | queued | pending |
| running | processing | in_progress |
| done | completed | completed |
| failed | failed | failed |
| canceled | canceled | cancelled |
| expired | (not used) | expired |
Normalizer
The function throws on a status it does not know, because a silent default would turn a new state into an infinite poll. Run it with node --experimental-strip-types or compile it first.
type State = "queued" | "running" | "done" | "failed" | "canceled" | "expired";
export type Job = { id: string; state: State; terminal: boolean };
const JOBS: Record<string, State> = { queued: "queued", processing: "running", completed: "done", failed: "failed", canceled: "canceled" };
const VIDEOS: Record<string, State> = { pending: "queued", in_progress: "running", completed: "done", failed: "failed", cancelled: "canceled", expired: "expired" };
const OPEN = new Set<State>(["queued", "running"]);
export function normalize(p: any): Job {
let id: string, raw: string, table: Record<string, State>;
if (p?.data?.sume_status) { id = p.data.request_id; raw = p.data.sume_status; table = JOBS; }
else if (typeof p?.status === "string" && p?.polling_url) { id = p.id; raw = p.status; table = VIDEOS; }
else throw new Error("unrecognised job payload");
const state = table[raw];
if (!state) throw new Error(`unknown status ${raw}`);
return { id, state, terminal: !OPEN.has(state) };
}
console.log(normalize({ data: { request_id: "job_1", sume_status: "processing" } }));
console.log(normalize({ id: "vid_1", polling_url: "https://api.sume.com/v1/videos/vid_1", status: "cancelled" }));Where this breaks
Read the response envelope too. Jobs wrap in data; the videos route does not. The videos route can also report expired, which the jobs route does not use. Where both are present, prefer the jobs route's own terminal flag over the derived one.
Related
If you are porting a call from OpenAI's Sora Videos API, see keeping a Sora-style call and mapping it onto Sume.
Sources
Related posts
More in Developers
- OpenAI Agents API hosted sandbox: which Sume hosts to allow
OpenAI's Agents API is in public beta with hosted or connected sandboxes. Which Sume hosts to allow, how to pass the MCP URL, and why the key stays in a secret.
- OpenAI Agents API sandbox: keep the Sume API key out of it
The OpenAI Agents API beta adds a sandbox and hosted-browser computer use. Where a Sume API key can live when an agent runs there, and what to hand it instead.
- OpenAI named no Videos API replacement: build it swappable
OpenAI's deprecation page names no successor to the Sora Videos API. Put video behind one interface, discover models from the catalog, and keep ids out of code.
- Mask file checklist for GPT Image 2.5: alpha, same size, under 50 MB
OpenAI says an edit mask must match the image in format and size, stay under 50 MB, and carry an alpha channel. Check your file before sending mask_url to Sume.
Written by Sume