job.canceled: the third Sume terminal event your handler forgets

Sume sends job.completed, job.failed and job.canceled and nothing else. A handler that covers only two leaves canceled jobs open. Route all three, 204 the rest.

5 min readSume
All posts

Sume job webhooks deliver exactly three events, all terminal: job.completed, job.failed and job.canceled. There are no progress or partial deliveries. The routing example in the SDK docs shows job.completed and job.failed, so a handler copied from it never closes a job that you or a teammate canceled. Route all three, and answer 204 for any event name you do not know, so a new event type never causes a retry storm.

The three events

Job webhook events, as of 2026-10-08
EventWhenPayload status
job.completedJob completed; a public result is availableOK, with payload.artifacts
job.failedJob failed with a public errorERROR, with an error object
job.canceledJob reached the canceled stateERROR, with an error object

When a job ends up canceled

Cancellation succeeds only before generation work starts. After generation starts, the cancel call returns 409 job_generation_already_started and the job finishes normally. So a job.canceled event means the work never ran. A cancel of an already canceled job is idempotent, which makes duplicate events harmless if you dedupe on job_id.

Your handler should therefore stop any downstream step waiting for that job, mark the item as canceled in your own table, and not retry it automatically. A person or a rule canceled it on purpose.

The router

The TypeScript below dispatches on event, not on the status field, and returns 204 for everything unknown. The verification step from verifyWebhook belongs before it.

type JobEvent = {
  event: string;
  job_id?: string;
  request_id?: string;
  status?: string;
  payload?: { artifacts?: { url: string; type: string }[] };
  error?: { code?: string; message?: string };
};

export function route(e: JobEvent): Response {
  switch (e.event) {
    case "job.completed":
      markDone(e.job_id!, e.payload?.artifacts ?? []);
      break;
    case "job.failed":
      markFailed(e.job_id!, e.error);
      break;
    case "job.canceled":
      markCanceled(e.job_id!);
      break;
    default:
      break; // unknown event: acknowledge, do not 500
  }
  return new Response(null, { status: 204 });
}

declare function markDone(id: string, a: unknown[]): void;
declare function markFailed(id: string, e: unknown): void;
declare function markCanceled(id: string): void;

Test with a fixture

Add one test per event, plus an unknown event. The unknown-event test is the one that protects you the day Sume adds a fourth type.

Poll fallback also sees canceled

The same rule holds when you poll. The terminal job statuses are completed, failed and canceled, and a loop that stops only on the first two runs until its deadline when a job was canceled. Use the terminal boolean from the status response instead of a hand-written list, and branch on sume_status afterward.

For /v1/videos polling the spelling is cancelled with two Ls. Treat both spellings as terminal if one loop serves both surfaces.

Only the member whose key or Agent turn created a job can cancel it. A cancel request after generation has begun is refused with 409 job_generation_already_started and details.cancelable: false, and the job completes or fails normally. A cancel of an already canceled job is idempotent. Between them, these rules mean you can see job.canceled, job.completed or job.failed for a job you tried to cancel, and your handler must accept all three.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume