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.

4 min readSume
All posts

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

Delivery behavior from the Sume webhook docs, read 2026-10-06
Handler outcomeWhat Sume doesYour risk
2xx inside 10 sDelivery doneNone
Slower than 10 s or non-2xxRetries, up to 10 attempts, 30 s apartSame event arrives again while work is half done
All attempts usedStops automatic retriesUse 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 Set is only for the demo. Use a unique constraint on job_id in 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}/status is the cheap way to reconcile.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume