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.

4 min readSume
All posts

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

Sume job webhook payload fields used in the schema, from the webhook guide and API source (read 2026-10-03)
Fieldjob.completedjob.failed or job.canceled
statusOKERROR
payloadobject with artifacts[]null
artifacts[]id, url, type, content_typenot present
errorabsentobject 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 passthrough on the error object. The public error carries more fields than the schema names.
  • Do not parse request_id into a different type. It is the job id, and you will want it as the key for deduplication.
  • Store job_id and event as 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

All Developers posts

Written by Sume