Bun 1.4.1 Bun.serve: a Sume webhook receiver on raw bytes
A Bun.serve routes handler that reads the request as bytes, verifies x-sume-webhook-signature, refuses an empty secret, and returns 2xx only after the check.

Short answer
Register a POST route in Bun.serve, read await req.arrayBuffer() so the signed bytes stay intact, and compare sume-v1= plus an HMAC-SHA256 of <timestamp>.<body> against every comma-separated entry in x-sume-webhook-signature. Bun 1.4.1, per its release notes, serves HTTP/2 on the same port as HTTP/1.1 through the same routes and fetch handler.
Sume's webhooks page gives the rules the handler enforces: a five-minute replay window, a secret named SUME_COM_WEBHOOK_SIGNING_SECRET, and job_id as your own idempotency key.
What the Bun release says about serving
Sume requires a public HTTPS webhook URL and rejects localhost, private-network and non-HTTPS URLs, so the TLS question is real. Terminate TLS in front of the process, or configure it on Bun.serve as the release notes show.
| Topic | What the notes say |
|---|---|
| HTTP/2 | supported on the same port as HTTP/1.1, same routes and fetch handler |
| Negotiation | over TLS, protocol chosen with ALPN |
| http1: false | refuses HTTP/1.x clients |
| Not yet over HTTP/2 | WebSockets and response trailers |
The receiver
The process refuses to start with an empty secret, so a missing environment variable can never turn into a server that accepts any signature. It answers 401 before parsing the JSON, and only then touches the event.
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; refusing to start");
function verified(ts, header, raw) {
if (!Number.isFinite(Number(ts)) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const digest = createHmac("sha256", secret).update(`${ts}.`).update(raw).digest("hex");
const want = Buffer.from("sume-v1=" + digest);
return header.split(",").map((e) => Buffer.from(e.trim()))
.reduce((hit, got) => (got.length === want.length && timingSafeEqual(got, want)) || hit, false);
}
Bun.serve({
port: 3000,
routes: { "/sume/webhook": { POST: async (req) => {
const raw = Buffer.from(await req.arrayBuffer());
const ts = req.headers.get("x-sume-webhook-timestamp") ?? "";
const sig = req.headers.get("x-sume-webhook-signature") ?? "";
if (!verified(ts, sig, raw)) return new Response("bad signature", { status: 401 });
const event = JSON.parse(raw.toString("utf8"));
console.log(event.event, event.job_id ?? event.run_id);
return new Response("ok");
} } },
});Behavior to design for
Store the event durably, then return a 2xx. Sume retries network errors and non-2xx answers up to 10 attempts in total, 30 seconds apart by default, with a 10 second timeout per attempt, so a slow handler burns attempts. Do the heavy work after the response is written.
Because retries and manual redelivers repeat the same event, key your store on job_id for job events or on the envelope's request_id for run events, and make the second write a no-op.
What Sume does and does not do
Sume sends terminal events only, signs the raw body, and supports Send test (a dummy webhook.test body to a URL you type) and Redeliver (the real terminal event with a fresh signature). It does not send progress events and does not follow redirects on run webhooks.
Test locally by signing a body yourself with the same HMAC and posting it with curl; a public URL is only needed for real deliveries.
Sources
Related posts
More in Developers
- Bun 1.4.1 Bun.write(path, response): save Sume artifacts to disk
Bun 1.4.1 streams a Response body to disk instead of buffering it. Read GET /v1/jobs/:id/result, then Bun.write each artifact URL, checking the status first.
- Bun.cron() for Sume jobs: an OS scheduler is not a job store
Bun 1.4 adds Bun.cron(), OS-level scheduling. Use it for a Sume reconcile pass over stored job ids, not as the place that remembers which jobs exist.
- Bun 1.4 fetch request compression: should Sume API calls use gzip?
Bun 1.4 can compress fetch request bodies. Sume's docs do not describe compressed requests, so leave it off and send media as public HTTPS URLs.
- Canary 10% of video jobs to Sume before cutover: sticky bucketing
Moving video traffic off a shut-down API: hash a stable key into a percent bucket so each customer stays on one backend, and raise Sume's share in steps.
Written by Sume