Zod schema for a Sume job webhook: a discriminated union on status
Model Sume's job webhook in Zod as a union on status OK and ERROR so TypeScript narrows to artifacts or error. Verify the signature first, then parse.

A Sume job webhook has two shapes. A completed job has status: "OK" and a payload.artifacts array. A failed or canceled job has status: "ERROR", a null payload and an error object. The request_id and job_id fields hold the same job id on both. In TypeScript that is a textbook discriminated union, and Zod can enforce it at the edge of your service so that handlers never see a half-formed event.
Order matters. Verify the HMAC signature against the raw bytes first, then parse the JSON, then validate it. Validation is a second line of defence for your own code, not a substitute for the signature check.
The shapes
| Field | job.completed | job.failed or job.canceled |
|---|---|---|
| status | OK | ERROR |
| payload | object with artifacts[] | null |
| artifacts[] | id, url, type, content_type | not present |
| error | absent | object with code and, on public errors, retryable and next_action |
The schema
The union below discriminates on status. The OK branch requires at least one artifact whose URL is HTTPS. The ERROR branch requires an error object with a code and lets other fields through with passthrough, since the API may add fields later and a strict schema would turn a harmless addition into a rejected delivery.
import { z } from "zod";
const Artifact = z.object({
id: z.string(),
url: z.string().startsWith("https://"),
type: z.string(),
content_type: z.string(),
});
const Base = { request_id: z.string(), job_id: z.string() };
export const SumeJobWebhook = z.discriminatedUnion("status", [
z.object({
...Base,
event: z.literal("job.completed"),
status: z.literal("OK"),
payload: z.object({ artifacts: z.array(Artifact).min(1) }),
}),
z.object({
...Base,
event: z.enum(["job.failed", "job.canceled"]),
status: z.literal("ERROR"),
payload: z.null(),
error: z.object({
code: z.string(),
retryable: z.boolean().optional(),
next_action: z.string().optional(),
}).passthrough(),
}),
]);Add this helper to the same file to narrow the parsed event. After the status check TypeScript knows which branch it holds, so artifacts and error are never both in play.
export function describe(body: unknown): string {
const r = SumeJobWebhook.safeParse(body);
if (!r.success) return "unrecognised: " + r.error.issues[0]?.path.join(".");
const e = r.data;
return e.status === "OK" ? e.payload.artifacts[0].url : `${e.event} ${e.error.code}`;
}Try it
Append this to the same file and run it with Bun, or with a TypeScript runner after installing zod. It prints the artifact URL for the completed event, job.failed media_invalid for the failed one, and an unrecognised line with the path to the first issue for an event whose artifacts array is empty. The media_invalid code is a placeholder, so use real codes from your logs.
const ok = { event: "job.completed", request_id: "job_1", job_id: "job_1", status: "OK",
payload: { artifacts: [{ id: "a1", url: "https://example.com/v.mp4", type: "video", content_type: "video/mp4" }] } };
const bad = { event: "job.failed", request_id: "job_2", job_id: "job_2", status: "ERROR",
payload: null, error: { code: "media_invalid", retryable: false } };
console.log(describe(ok));
console.log(describe(bad));
console.log(describe({ ...ok, payload: { artifacts: [] } }));Choices worth making on purpose
- Return 2xx for a delivery that verifies but does not parse, and alert on it. Any non-2xx is retried, up to 10 attempts, and a body that will never parse will fail every time.
- Keep
passthroughon the error object. The public error carries more fields than the schema names. - Do not parse
request_idinto a different type. It is the job id, and you will want it as the key for deduplication. - Store
job_idandeventas a pair. A job can reach a delivery more than once, such as after a manual redeliver.
The payload and header rules are in the webhook guide, and the error fields are in the error reference. Zod describes unions on its API page.
Sources
Related posts
More in Developers
- Zod z.toJSONSchema io: "input" for a Sume request body schema
Zod's z.toJSONSchema outputs the output type by default. Pass io: "input" to describe what a client may send, and note which Zod types cannot be represented.
- Ask for the aspect ratio first: an MCP input_required round trip
MCP multi round-trip requests let a tool answer input_required to ask for an aspect ratio or spend approval before a render, with state in requestState.
- Browser voice app that starts Sume jobs: keep the key on your server
Voice apps run in the browser over WebRTC, but Sume keys belong on a server. A route handler that holds the key, allowlists models, reuses idempotency keys.
- C2PA 2.2: file types that can carry credentials vs Sume outputs
C2PA 2.2 manifests can be embedded in JPEG, PNG, WebP, SVG, MP4, MOV and more. How that list lines up with the formats Sume image and video jobs return.
Written by Sume