Sume webhook handler over 10 seconds: acknowledge first, then work
Sume gives each webhook attempt 10 seconds. Verify, answer 2xx, then process in the background and dedupe on job_id. Node sample you can run locally.

Sume delivers each job webhook with a 10 second timeout per attempt, and retries up to 10 times with fixed 30 second spacing. If your handler copies the image to storage, writes rows and publishes a page before it answers, a slow day turns into timeouts, retries and duplicate work. The webhook docs tell you to treat job_id as the idempotency key.
The shape that avoids this is short: verify the signature on the raw body, answer with a 2xx, and do the slow part afterwards.
What a slow handler costs
| Handler outcome | What Sume does | Your risk |
|---|---|---|
| 2xx inside 10 s | Delivery done | None |
| Slower than 10 s or non-2xx | Retries, up to 10 attempts, 30 s apart | Same event arrives again while work is half done |
| All attempts used | Stops automatic retries | Use POST /v1/jobs/{id}/webhook/redeliver (jobs:write) |
Node sample
It refuses to start with an empty secret, checks the 300 second timestamp window, accepts any sume-v1= entry in the header (rotation sends two), and answers 202 before the slow work runs.
import http from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty");
const seen = new Set();
function valid(raw, h) {
const ts = h["x-sume-webhook-timestamp"];
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const want = createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex");
return String(h["x-sume-webhook-signature"] ?? "").split(",").some((p) => {
const got = p.replace("sume-v1=", "");
return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want));
});
}
async function work(event) { // the slow part: copy media, write rows, publish
if (seen.has(event.job_id)) return; // dedupe on job_id
seen.add(event.job_id);
console.log("processing", event.job_id, event.event);
}
http.createServer((req, res) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
if (!valid(raw, req.headers)) return res.writeHead(401).end();
res.writeHead(202).end(); // acknowledge inside the 10 s window
setImmediate(() => work(JSON.parse(raw)).catch(console.error));
});
}).listen(8787);Make the background step safe
- The in-memory
Setis only for the demo. Use a unique constraint onjob_idin your database so a retry or redelivery cannot insert twice. - On a serverless host that freezes the process after the response, enqueue the event to a durable queue instead of using
setImmediate. - Keep a poll as backup: a webhook that never arrived is invisible, and
GET /v1/jobs/{id}/statusis the cheap way to reconcile.
Sources
Related posts
More in Developers
- Sume webhook rotation: upgrade the verifier before you click Rotate
During a rotation window Sume sends two signatures in one header. A receiver that compares the whole header for equality fails every delivery. Fix it first.
- SvelteKit +server.ts endpoint for an AI video webhook: request.text()
A SvelteKit POST handler reads request.text(), verifies Sume's HMAC over timestamp.body with node:crypto and returns 401 for bad or missing signatures.
- Swap the AI image model without a redeploy: JSON config hot reload
Read the Sume image model id from a JSON file that reloads when it changes, so a gpt-image-1 shutdown fix is a one-line edit with no deploy. Python, stdlib.
- Test a Sume poll loop without waiting: inject sleep, assert delays
Unit test a job poll loop in milliseconds by injecting the fetch and the sleep. Assert that next_poll_after_seconds is obeyed and the 20-minute deadline holds.
Written by Sume