A zod schema for the Sume /v1/videos poll response, ported from Sora
Replace Sora's four-status type with a zod schema for Sume's five statuses. It rejects a completed job without unsigned_urls and a failed one without an error.

A zod schema for the Sume poll response needs five statuses, not Sora's four: pending, in_progress, completed, failed and cancelled. The schema below also refuses a completed job with no unsigned_urls and a failed job with no error, so a malformed response fails at the boundary instead of three functions later.
What changes from the Sora type
OpenAI's Sora guide, read 2026-10-07, listed queued, in_progress, completed and failed. A TypeScript union copied from that guide would reject cancelled, which Sume can return, and would never accept pending. Rename the first and add the last.
The status enum is the line most likely to bite. A client that parses with a Sora enum fails on the first pending response, and a client that handles only the happy path hangs forever on cancelled. Both are one-line mistakes that a schema makes loud.
The response contract
| Field | When present | Schema rule |
|---|---|---|
| id, polling_url, model | Always | Strings, polling_url must be a URL |
| status | Always | One of five values |
| unsigned_urls | completed | Non-empty array of URLs |
| error | failed | Non-empty string |
| usage.cost | After billing | Number, optional |
The schema
Save this as schema.mjs. It needs zod 4, where z.url() is a top-level helper.
import { z } from "zod";
const Status = z.enum(["pending", "in_progress", "completed", "failed", "cancelled"]);
export const VideoJob = z
.object({
id: z.string(),
polling_url: z.url(),
status: Status,
model: z.string(),
unsigned_urls: z.array(z.url()).optional(),
error: z.string().optional(),
usage: z.object({ cost: z.number() }).optional(),
})
.superRefine((job, ctx) => {
if (job.status === "completed" && !job.unsigned_urls?.length)
ctx.addIssue({ code: "custom", message: "completed without unsigned_urls" });
if (job.status === "failed" && !job.error)
ctx.addIssue({ code: "custom", message: "failed without error" });
});
export const isTerminal = (s) => ["completed", "failed", "cancelled"].includes(s);Parsing inside the poll loop
The loop below submits, then parses every poll. It stops on any of the three terminal states, and the parse throws if the shape is wrong, which is what you want in a job runner.
import { VideoJob, isTerminal } from "./schema.mjs";
const base = process.env.BASE ?? "https://api.sume.com";
const auth = { Authorization: `Bearer ${process.env.SUME_API_KEY}` };
const submit = await fetch(`${base}/v1/videos`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ model: "wan-3.0", prompt: "A paper boat on a rain puddle", duration: 4 }),
});
let job = VideoJob.parse(await submit.json());
while (!isTerminal(job.status)) {
await new Promise((r) => setTimeout(r, Number(process.env.POLL_MS ?? 30000)));
job = VideoJob.parse(await (await fetch(job.polling_url, { headers: auth })).json());
}
console.log(job.status, job.unsigned_urls?.[0]);Strict where it matters
Keep the schema strict about the fields you read and loose about everything else. Zod ignores unknown keys by default, so a new field on the response will not break you. The route and fields are documented in the video generation docs, and the terminal-state rules are in jobs and results.
One more habit helps: export the inferred type with z.infer and use it in your UI, so the five statuses flow into your switch statements and the compiler tells you when a case is missing. Add a default branch that throws for anything unexpected, so a future sixth status shows up in your logs instead of as a silent spinner.
Two job APIs
If the same code also calls /v1/jobs, which uses processing and canceled spelling, use the normalizer post rather than widening this enum.
Sources
Related posts
More in Developers
- 10-minute 1080p YouTube video at 8 Mbps is about 629 MB: $1.00 on Sume
At YouTube's 8 Mbps for 1080p plus 384 kbps audio, 600 seconds is 628.8 MB. A 600 second Sume Timeline render at 1920x1080 costs $1.00. The arithmetic.
- A 10-minute TikTok ad from 120 five-second slots: 600 s, $1.00
Timeline takes up to 200 slots and 1,800 s. 120 slots of 5 s make exactly 600 s, which meets TikTok's 10-minute ad limit and bills 10 minutes, $1.00.
- 100 Omni clips on Sume Pro: queue_full after 24 jobs
Sume Pro runs 4 jobs at once and accepts 24 in total. Submitting 100 Omni clips at once returns 429 queue_full. A Python wave loop that retries safely.
- Re-render 100 Sora prompts at 8s 720p 9:16: six Sume models priced
A 100-prompt library of 8-second 720p vertical clips costs $60.00 to $242.00 on Sume depending on the model. Exact per-clip cents and the arithmetic.
Written by Sume