Zod 4 discriminated union for Sume job and run webhooks (TypeScript)
Parse Sume job.* and format.run.terminal webhooks with one Zod 4 discriminatedUnion: typed branches, degraded runs, oversized receipts. Tested with Zod 4.

Sume sends two webhook families through one signature scheme: job events (job.completed, job.failed, job.canceled) and run events such as format.run.terminal. In TypeScript, verify the signature first, then parse the raw body with a Zod 4 discriminatedUnion on event, so each branch has its own typed fields and an unknown event fails loudly in one place instead of crashing a handler.
I ran the sample below with Zod 4 against bodies modelled on Sume's docs: a completed job, a failed job, a degraded run, a run whose receipt was too large to send inline, and two bodies that should be rejected.
Why do the two families need separate shapes?
Sume's docs are explicit that the event sets do not overlap and the payloads differ. A job webhook names a job and carries payload.artifacts. A run webhook carries the full run receipt, an outcome of ok, degraded or error, and a run_id. The dedupe key is also different: job_id for jobs, and the envelope's request_id, which equals run_id, for runs. A handler that treats them as one shape ends up with optional fields everywhere, which is how a degraded run gets shipped as a success.
The union fixes that by making the shape depend on the event, so the compiler tells you which fields exist on which branch.
| Field | Job events | Run events |
|---|---|---|
event | job.completed, job.failed, job.canceled | format.run.terminal (also action.run.terminal, agent.run.terminal) |
| Dedupe key | job_id | request_id, equal to run_id |
| Outcome | status: OK or ERROR | outcome: ok, degraded or error |
| Result | payload.artifacts | payload, the run receipt |
| Failure detail | error object | error with code and message |
| Canceled | job.canceled is sent | No webhook is sent for a canceled run |
What does the parser look like?
The objects use .loose() so a field Sume adds later does not break parsing. Run payload is nullable because the docs say a receipt over 1 MiB is replaced by payload: null with an error that carries a result_url. The router returns a small decision object and leaves the side effects to your code. Call it only after the signature has been checked against the raw body.
In a TypeScript file, type SumeEvent = z.infer<typeof SumeEvent> gives the same union to the rest of your code, and narrowing on event then works in an if or a switch. Keep the parser at the edge of the system, in the route handler, and pass the typed result inward. Everything after that point can trust the shape, and the only place that ever handles a malformed body is the one place that has the raw request to log.
import * as z from "zod";
const Err = z.object({ code: z.string(), message: z.string() }).loose();
const Job = z.object({
event: z.enum(["job.completed", "job.failed", "job.canceled"]),
job_id: z.string(),
status: z.enum(["OK", "ERROR"]),
payload: z.object({ artifacts: z.array(z.object({ url: z.string() }).loose()) }).loose().nullish(),
error: Err.nullish(),
}).loose();
const Run = z.object({
event: z.literal("format.run.terminal"),
run_id: z.string(),
outcome: z.enum(["ok", "degraded", "error"]),
payload: z.object({ primary_output_url: z.string().nullish() }).loose().nullable(),
error: Err.nullable(),
}).loose();
const SumeEvent = z.discriminatedUnion("event", [Job, Run]);
export function route(rawBody) {
const e = SumeEvent.parse(JSON.parse(rawBody)); // call after the signature check
if (e.event.startsWith("job.")) return { key: e.job_id, ok: e.status === "OK" };
if (e.payload === null) return { key: e.run_id, fetch: e.error?.result_url }; // over 1 MiB
return { key: e.run_id, ok: e.outcome === "ok", review: e.outcome === "degraded" };
}What did the tests show?
The completed job and the failed job routed on job_id with ok true and false. The degraded run returned ok: false with review: true, which is the case that a check on status alone would have shipped, because the docs say status is OK there while output is null. The oversized receipt took the fetch branch and returned its result_url.
The two bad bodies both threw a ZodError: an agent.run.terminal event, which this union does not list, failed as an invalid_union, and a run with an unknown outcome failed on the outcome path. That is the behaviour you want from the parser. What you do with the throw is a separate decision.
- Add
action.run.terminalandagent.run.terminalbranches if you call those surfaces. - Acknowledge with a
2xxafter logging a parse failure of a correctly signed body; a non-2xx is retried up to 10 times and will fail the same way. - Dedupe on the key before doing any work, and store the event before processing.
- Treat
degradedas its own case, not as success or failure. - Do not wait for a webhook after canceling a run; poll the status URL instead.
What does the union not protect you from?
Parsing proves shape, not authenticity. A forged body can be perfectly shaped, so the signature check must come first and must use the raw bytes, not the re-serialized JSON. Sume's webhook docs give a TypeScript verifier that handles the rotation header, and the same secret covers job and run deliveries.
It also cannot tell you about deliveries that never arrive. Ten failed attempts leave a finished job without a callback, so keep a status poll as a backstop. And a degraded run is a real outcome that usually points to an output_schema problem upstream, which is a fix in the request, not in the handler.
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