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.

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).
| Rule | Job webhooks | Run webhooks |
|---|---|---|
| Success | Any 2xx within 10 seconds | Any 2xx within 10 seconds |
| Attempts | Up to 10 in total | Up to 10 in total |
| Spacing | Fixed delay, 30 seconds by default | Exponential from 30 seconds with jitter, capped at one hour |
| Redirects | Not stated on the job page; register the final URL | Not followed; a 3xx is a failed attempt |
| Dedupe key | job_id | request_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
- Empty webhook secret in staging: fail closed at boot, not per request
An unset SUME_COM_WEBHOOK_SIGNING_SECRET makes an HMAC over an empty key that anyone can forge. Refuse to start, then accept either entry during a rotation.
- Test a webhook endpoint before go-live: a Sume CI gate (Python)
Use POST /v1/webhooks/test-deliveries to fire a signed webhook.test at your deployed URL and fail the deploy unless it answers 2xx. Python script included.
- Webhook timestamp tolerance: Sume 300 s versus Stripe 5 min
Sume's verifyWebhook rejects deliveries older than 300 seconds by default. Why clock skew matters, why toleranceSeconds 0 is a trap, and how to fix drift.
- WebVTT cue text cannot contain --> : clean Sume STT segments in Python
WebVTT forbids the arrow sequence inside cue text and wants 3-digit milliseconds. A short Python script turns Sume STT sentence segments into a valid .vtt file.
Written by Sume