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.

5 min readSume
All posts

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

Poll fields the schema checks (read 2026-10-07)
FieldWhen presentSchema rule
id, polling_url, modelAlwaysStrings, polling_url must be a URL
statusAlwaysOne of five values
unsigned_urlscompletedNon-empty array of URLs
errorfailedNon-empty string
usage.costAfter billingNumber, 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

All Developers posts

Written by Sume