Sume webhook handler in Node: store first, answer 2xx, process later

Sume gives a webhook endpoint 10 seconds per attempt. Verify the raw body, persist the event, answer 2xx, then do slow work off the request path. Node sample.

4 min readSume
All posts

A Sume webhook handler should verify the signature on the raw bytes, write the event to durable storage, return a 2xx, and only then do the slow work. Sume waits 10 seconds for each delivery attempt, and a slow endpoint burns the attempt budget and gets retried.

That matters because a terminal event is sent a limited number of times: up to 10 attempts in total for both job webhooks and run webhooks. If your handler downloads a video, calls another API and updates a database before answering, one slow dependency can turn a finished job into an exhausted delivery.

What the delivery contract gives you

The numbers below come from the Webhooks page and the Format Runs and results page. They are the budget your handler lives inside (read 2026-10-03).

Sume webhook delivery rules that shape a handler (read 2026-10-03)
RuleJob webhooksRun webhooks
SuccessAny 2xx within 10 secondsAny 2xx within 10 seconds
AttemptsUp to 10 in totalUp to 10 in total
SpacingFixed delay, 30 seconds by defaultExponential from 30 seconds with jitter, capped at one hour
RedirectsNot stated on the job page; register the final URLNot followed; a 3xx is a failed attempt
Dedupe keyjob_idrequest_id (equal to run_id)

A handler that does only the fast part

The sample uses nothing but Node's standard library. It reads the raw bytes before any JSON parsing, refuses to run with an empty secret, accepts any one of the comma-separated sume-v1= entries so a secret rotation does not break it, appends the event to a file and answers 200. Save it as hook.mjs, set SUME_COM_WEBHOOK_SIGNING_SECRET, and run node hook.mjs.

import http from "node:http";
import crypto from "node:crypto";
import { appendFile } from "node:fs/promises";

const SECRET = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";

function verified(raw, ts = "", header = "") {
  if (!SECRET || !/^\d+$/.test(ts)) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const mac = crypto.createHmac("sha256", SECRET).update(`${ts}.`).update(raw);
  const want = Buffer.from(`sume-v1=${mac.digest("hex")}`);
  return header.split(",").map((entry) => {
    const got = Buffer.from(entry.trim());
    return got.length === want.length && crypto.timingSafeEqual(got, want);
  }).includes(true);
}

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", async () => {
    const raw = Buffer.concat(chunks);
    const h = req.headers;
    if (!verified(raw, h["x-sume-webhook-timestamp"], h["x-sume-webhook-signature"])) {
      return res.writeHead(401).end();
    }
    await appendFile("events.ndjson", JSON.stringify(JSON.parse(raw)) + "\n");
    res.writeHead(200).end("ok");
  });
}).listen(3000);

Where the slow work goes

The file is a stand-in for a real queue or database table. What matters is the order: verify, persist, answer. A separate worker then reads the stored events, downloads media from the result and updates your records.

Dedupe in that worker, not in the handler. Retries repeat the same job_id (or request_id for runs), so a unique key on that column turns a replayed delivery into a no-op. For the ordering of several run events for one run, see run webhook dedupe on request_id.

Keep polling as the backup. Ten refused attempts leave a failed delivery and a job that still reached its real terminal state, so a reconciler that reads status_url for jobs you never heard back about closes the gap. If your endpoint was down, redeliver re-sends the terminal event without spending one of the automatic attempts.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume