TypeScript exhaustive switch over a Sume run's terminal status
A run ends as completed, failed, canceled or skipped, and the last two send no webhook. Use a never check so a new status fails the build.

Type the terminal status as a union of four strings, completed, failed, canceled and skipped, and switch on it with a never assignment in the default branch. TypeScript then refuses to compile the day a fifth status is added to your types. Two of the four statuses never produce a webhook, so the same switch is also where you decide that a poll must cover them.
The reason to care is cost. A paid run that ends in a status your code does not handle is a silent hole. The order stays open in your database, the customer waits, and nobody sees an error, because nothing threw. A compile time check moves that failure to your build, where it is cheap.
Status and outcome are two fields
A Format, Action or Agent run is terminal when it reaches one of these four. The run also carries an outcome of ok, degraded or error, and the two fields answer different questions. The status says how the run ended. The outcome says how good the result is. A run can be completed with outcome degraded, and your code should not treat that as a clean success.
Keep the two fields apart in your own types as well. A RunView with a required status and an optional outcome makes the compiler remind you that a completed run still needs a review step when its outcome is not ok. Many teams collapse both into a single boolean named success, and then cannot tell a clean run from a degraded one when a customer complains.
What each status means for your code
Each status has a different consequence for your system, and the table lists them together with the webhook behaviour from the docs.
| Status | Webhook sent | What to do |
|---|---|---|
| completed | Yes, a terminal event | Read the receipt, check outcome, store artifacts |
| failed | Yes, a terminal event | Read the error code, decide on a retry with a new key |
| canceled | No | Learn about it by polling, mark the order as stopped |
| skipped | No | Learn about it by polling, usually an active run policy fired |
The switch
The sample below is a complete function that compiles with strict settings. The never line is the guard. If someone widens TerminalStatus, the build breaks at that line, and you handle the new case on purpose.
Notice what the sample leaves out. It does not poll, it does not call the network and it does not parse JSON. The decision is a pure function of a typed value, so it is trivial to test, and you can reuse it from a webhook handler, from a poll loop and from a nightly reconciliation script. Each of the three then makes the same choice for the same status.
type TerminalStatus = "completed" | "failed" | "canceled" | "skipped";
interface RunView {
status: TerminalStatus;
outcome?: "ok" | "degraded" | "error";
}
export function nextStep(run: RunView): string {
switch (run.status) {
case "completed":
return run.outcome === "ok" ? "publish" : "review";
case "failed":
return "retry-with-new-key";
case "canceled":
return "mark-stopped";
case "skipped":
return "mark-skipped";
default: {
const unreachable: never = run.status;
return unreachable;
}
}
}
console.log(nextStep({ status: "completed", outcome: "degraded" }));
Retry means a new key
Retrying a failed run needs a fresh idempotency key. The same key with the same body returns the original receipt with idempotency_hit: true, so reusing it would only give you the failure again. A same key with a different body returns 409 idempotency_conflict. Derive keys from stable ids such as tenant, order, Format slug and version, and add an attempt number when you retry on purpose.
A good retry policy has a ceiling. Count attempts per order, stop after a small number you choose, and escalate to a person. Do not retry a canceled run at all, because a person or a policy stopped it on purpose.
Cover the silent statuses with a poll
Because canceled and skipped runs send no webhook, never rely on the webhook alone. Poll the run on a slow timer for anything that did not report, or use waitForRun, which polls for you and tolerates up to 6 transient failures. The SDK keeps the run family as a required argument, so pass family explicitly. The default timeout of waitForRun is 10 minutes, and a timeout does not cancel the run.
If you use the SDK, the wait helper throws on a transport problem or a timeout, and it resolves with the final snapshot otherwise. Catch the timeout error separately, and treat it as unknown state, not as a failure of the run.
Webhook side of the same switch
Run webhooks deliver format.run.terminal, action.run.terminal and agent.run.terminal. Route on the event field and answer 204 to events you do not know, so new event types do not cause a retry storm. Dedupe on request_id, because delivery is at least once and a replay can arrive after a manual redeliver.
Sources
Related posts
More in Developers
- TypeScript webhook verifier for Wan 3.0 clips: refuse an empty secret
A TypeScript verifier for Sume webhooks: HMAC SHA 256 over timestamp.body, rotation entries, a 5 minute replay window and a hard refusal of an empty secret.
- Ukrainian text to speech API: set language uk and price a script
Cartesia Sonic 3.6 lists Ukrainian. Send language uk to Sume TTS 1.0, handle the 409 voice mismatch, and price a 5,000-character script at $0.24.
- usage_reservation_unavailable: a Sume job failed before it started
The usage reservation could not be placed, so Sume failed the queued job and gave back any hold. Nothing ran. Resubmit with the same Idempotency-Key.
- Use Decimal, not float, to reconcile Sume video costs to the cent
Sume bills list x 1.25 and rounds each video job up to the cent. Python Decimal with ROUND_CEILING reproduces the bill exactly, where floats drift by a cent.
Written by Sume