One Sume webhook endpoint for job and run events: route on event

Job and run webhooks share one HMAC scheme but not one payload. A 27-line TypeScript handler verifies once, routes on event, and answers 204 to the rest.

5 min readSume
All posts

A single endpoint can receive both Sume job webhooks and Sume run webhooks, because they use the same sume-v1 signature. Verify once with verifyWebhook from @sume-com/sdk, then switch on event. Do not expect run_id in every body, and answer 204 to any event name you do not know, so a new event type does not cause a retry storm.

The payloads differ even though the signature does not. A job event carries job_id and a payload.artifacts list. A run event carries the full run receipt. Routing on the event field keeps the two shapes apart.

The events

Sume sends terminal events only. There are no progress or partial deliveries, so a webhook cannot drive a progress bar.

Events that reach a Sume webhook receiver (Sume docs read 2026-10-08)
EventSurfaceDedupe on
job.completed, job.failed, job.canceledGeneration jobs from /v1/models/... and model endpointsjob_id
action.run.terminal, format.run.terminal, agent.run.terminalAction, Format, and Agent Completion runsrequest_id of the run receipt
webhook.testSend test from the dashboardNothing. It is not a job or a run.

The handler

It refuses to run without a secret, reads the raw body before any parsing, and stores the event before it returns. saveOnce is yours to write; a unique database key on the string it receives is enough to make retries harmless.

import { verifyWebhook } from "@sume-com/sdk";

export async function POST(request: Request) {
  const body = await request.text();
  const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
  if (!secret) return new Response("webhook secret not configured", { status: 500 });

  const ok = await verifyWebhook({ body, headers: request.headers, secret });
  if (!ok) return new Response("bad signature", { status: 401 });

  const event = JSON.parse(body);
  switch (event.event) {
    case "job.completed":
    case "job.failed":
    case "job.canceled":
      await saveOnce(`job:${event.job_id}`, event);
      break;
    case "format.run.terminal":
      await saveOnce(`run:${event.request_id}`, event);
      break;
    default:
      break; // webhook.test and future events: acknowledge, do not retry
  }
  return new Response(null, { status: 204 });
}

declare function saveOnce(key: string, event: unknown): Promise<void>;

Behavior to plan for

Job webhooks need a public HTTPS webhook_url; localhost and private-network URLs are rejected when you submit.

  • Return a 2xx only after the event is stored. Sume retries network errors and non-2xx responses, up to 10 attempts in total.
  • Each attempt has a 10-second timeout, so a slow endpoint spends the budget.
  • After ten refused attempts the job is still terminal; only the delivery failed. Keep polling the status URL as a backstop.
  • Redeliver re-sends the real terminal event with a fresh timestamp and signature, and does not use one of the automatic ten.

Where the secret comes from

Read it from the Webhooks tab of the dashboard, or from GET /v1/webhooks/signing-secret with an account:read key, and store it as SUME_COM_WEBHOOK_SIGNING_SECRET. It is derived per workspace, so a valid signature proves Sume signed the delivery for you. It is not your API key; keep the two apart.

Keeping the handler small

Resist the urge to do work inside the request. Verify, store, and return. A separate worker reads the stored events, downloads media from the job artifacts, and updates your own records. The request then finishes well inside the 10-second timeout, and a crash in the worker never causes a redelivery.

Store the event name next to the id. A job and a run may share nothing, but your table will be easier to query when each row says which kind it is.

Add a test for each branch: a job event, a run event, an unknown event, and a bad signature. The last one should return a client error and store nothing.

If the worker finds an event it cannot process, mark the row as parked and move on. Do not return an error to Sume for it, because a retry carries the same body and will fail the same way.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume