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.

5 min readSume
All posts

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.

Bun.serve facts from v1.4.1 (read 2026-10-03)
TopicWhat the notes say
HTTP/2supported on the same port as HTTP/1.1, same routes and fetch handler
Negotiationover TLS, protocol chosen with ALPN
http1: falserefuses HTTP/1.x clients
Not yet over HTTP/2WebSockets 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

All Developers posts

Written by Sume