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
- Which MCP server lets Claude Code or Cursor generate video and images?
MCP servers that let Claude Code and Cursor make video and images: Sume, fal, Replicate, Runway, Higgsfield. Endpoints, sign-in, billing, setup.
- Idempotency keys for AI video APIs: retry without paying twice
An idempotency key makes a retried create return the original run or job instead of a second paid one. How Sume's Idempotency-Key works on each API.
- Signed webhooks for Sume video runs: events, retries, verification
Sume sends one HMAC-SHA256 signed POST when a Format, Action, or Agent Completion run completes or fails. Verify the raw body and dedupe on request_id.
- Spend caps for unattended AI agents: how Sume bounds each run
An unattended agent has no one to approve spend, so Sume caps generation per run: required on Agent Completions, and up to $500 on Format runs.
Written by Sume