One webhook handler for Sume agent, Format and schedule runs

A single receiver can handle all three Sume run types: verify the HMAC over timestamp.body, dedupe on request_id, and branch on outcome (ok, degraded, error).

5 min readSume
All posts

One endpoint can receive Sume Agent Completions, Format runs and schedule runs. Verify the sume-v1 HMAC-SHA256 signature over <timestamp>.<raw_body>, dedupe on request_id, and branch on outcome rather than status. The event name tells you the run type: agent.run.terminal, format.run.terminal or action.run.terminal.

What arrives

Because the envelope is identical across run types, the receiver does not need three routes. Route on event, load the run by request_id from your own table, and update it. Do the work after you respond, because a slow handler consumes the 10 second attempt window and brings on a retry.

Run webhook envelope (read 2026-10-07)
FieldMeaning
eventOne terminal event per run family
request_idEquals the run id and is stable across retries; the dedupe key
statusOK or ERROR; binary
outcomeok, degraded or error; branch on this
payloadThe run receipt, byte-identical to the poll response data; null over 1 MiB
created_atWhen Sume built this delivery; use it to order deliveries

A receiver that follows the rules

The headers are x-sume-webhook-timestamp and x-sume-webhook-signature. Verify against the raw body before any JSON parse, reject timestamps outside a five-minute window, and compare in constant time. The sample below refuses an empty secret, which is the failure that silently disables a check when an environment variable is missing.

import crypto from "node:crypto";

export function verify(raw, ts, sig, secret, tol = 300) {
  if (!secret) return false; // refuse an empty secret
  const t = Number(ts);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > tol) return false;
  const mac = crypto.createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
  const a = Buffer.from(sig || "");
  const b = Buffer.from(`sume-v1=${mac}`);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

export function route(body, seen) {
  if (seen.has(body.request_id)) return "duplicate";
  seen.add(body.request_id);
  switch (body.outcome) {
    case "ok": return `ship ${body.event}`;
    case "degraded": return "review artifacts";
    default: return "retry or alert";
  }
}

Why branch on outcome

A run can complete, bill you and produce real media but fail to project it into your output_schema. The envelope then says status: OK with outcome: degraded and output: null, with the reason in output_error. A handler that reads only status cannot tell that case from a clean delivery.

Delivery behavior to plan for

  • Return a 2xx quickly and process after responding. Each attempt has a 10 second timeout.
  • Sume makes up to 10 attempts with exponential backoff, then marks webhook_delivery.status as exhausted. Redirects are not followed.
  • A payload: null with payload_too_large means fetch the receipt from result_url; the run itself did not fail.
  • Canceled and skipped runs deliver nothing.
  • To replay a real terminal delivery use POST /v1/format-runs/{run_id}/webhook/redeliver, which keeps the same secret and fingerprint.

Where the secret comes from

Read your signing secret on the dashboard Webhooks tab or from GET /v1/webhooks/signing-secret with an account:read key, and keep it as SUME_COM_WEBHOOK_SIGNING_SECRET. Compare the x-sume-webhook-secret-fingerprint header with the fingerprint on the dashboard instead of logging the secret. For TypeScript, @sume-com/sdk ships verifyWebhook.

Sources

Related posts

More in Agents

All Agents posts

Written by Sume