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.

5 min readSume
All posts

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.

Fields per webhook family, from Sume's job and run webhook docs, read 2026-10-03
FieldJob eventsRun events
eventjob.completed, job.failed, job.canceledformat.run.terminal (also action.run.terminal, agent.run.terminal)
Dedupe keyjob_idrequest_id, equal to run_id
Outcomestatus: OK or ERRORoutcome: ok, degraded or error
Resultpayload.artifactspayload, the run receipt
Failure detailerror objecterror with code and message
Canceledjob.canceled is sentNo 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.terminal and agent.run.terminal branches if you call those surfaces.
  • Acknowledge with a 2xx after 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 degraded as 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

All Developers posts

Written by Sume