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
- 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.
- 4K 3840x2160 for YouTube: Sume Timeline output stops at 2160 per edge
YouTube lists 35-45 Mbps for 4K. Sume Timeline allows even width and height from 256 to 2160, so 3840x2160 is refused. What you can render instead.
- 720x1280 vs 1080x1920 for a 60 s Short: 40 MB vs 63 MB
YouTube lists 5 Mbps for 720p and 8 Mbps for 1080p. A 60 second Short is 40.38 MB vs 62.88 MB with audio. Set output.width and height in Sume Timeline.
- A 12-minute video job costs 24 status reads at 30 s and 360 at 2 s
Poll cadence arithmetic for Sume video jobs: 30-second polls, the 2-second SDK floor, and the 20-minute client deadline, with a runnable calculation.
Written by Sume