Elysia on Bun: verify a Sume webhook with parse: "text"

An Elysia route that checks the Sume HMAC signature on the raw string, refuses an empty secret, skips duplicates and answers webhook.test. Tested on Bun 1.4.

5 min readSume
All posts

To verify a Sume job webhook in Elysia, set parse: "text" on the route so the handler gets the body as the exact string Sume sent. Hash <timestamp>.<body> with HMAC-SHA256, compare it to the sume-v1= entries in x-sume-webhook-signature, and only then call JSON.parse. The code below was run on Bun 1.4 with Elysia 1.4.

Read the raw bytes in Elysia

By default Elysia parses a JSON request for you. I checked with app.handle(): a body of {"a": 1} reached the handler as an object, and JSON.stringify of that object was {"a":1}, which is a different string. The signature covers the bytes Sume sent. Parsing the JSON and serialising it again can change spacing or key order, so the HMAC of the re-serialised text will not match.

The route option parse: "text" skips that step and passes the string through. The handler then owns the parse.

  • parse: "text" on the route that receives the webhook, and nowhere else.
  • Read the two headers from the headers object. Elysia lower-cases the names.
  • Set set.status = 401 for a failed check and return a short string.

The receiver

The program has no dependencies besides elysia. It reads the secret at start-up, so an empty variable throws before the server listens. Install with bun add elysia and run bun index.ts.

import { Elysia } from "elysia";
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<string>();
function verify(raw: string, ts: string, header: string): boolean {
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const want = Buffer.from("sume-v1=" + createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex"));
  return header.split(",").some((e) => {
    const got = Buffer.from(e.trim());
    return got.length === want.length && timingSafeEqual(got, want);
  });
}

new Elysia()
  .post("/hooks/sume", ({ body, headers, set }) => {
    const raw = body as string;
    if (!verify(raw, headers["x-sume-webhook-timestamp"] ?? "", headers["x-sume-webhook-signature"] ?? "")) {
      set.status = 401;
      return "bad signature";
    }
    const event = JSON.parse(raw);
    if (event.job_id && !seen.has(event.job_id)) {
      seen.add(event.job_id);
      console.log(event.event, event.job_id, event.payload?.artifacts?.[0]?.url);
    }
    return "ok";
  }, { parse: "text" })
  .listen(Number(process.env.PORT ?? 3000));

What the check has to do

The rules come from the webhooks guide. Sume signs the raw JSON body, so the receiver hashes the bytes it received and never a re-serialised object. During a secret rotation the signature header can hold several comma-separated entries, newest first, and a delivery is good when any one matches.

The secret comes from SUME_COM_WEBHOOK_SIGNING_SECRET. The program above stops at start-up when the variable is empty, so a missing secret cannot turn into an endpoint that accepts everything.

Sume job webhook delivery rules (read 2026-10-07)
RuleValue
Eventsjob.completed, job.failed, job.canceled (terminal only)
SignatureHMAC-SHA256 over <timestamp>.<raw_body>, header sume-v1=<hex>
Replay windowReject timestamps outside about 5 minutes (300 s used here)
AttemptsUp to 10, a fixed 30 s apart by default, 10 s timeout each
AcknowledgeAny 2xx after you stored the event
Dedupe keyjob_id
Send testPOST /v1/webhooks/test-deliveries (account:write), body is webhook.test
RedeliverPOST /v1/jobs/{job_id}/webhook/redeliver (jobs:write), fresh timestamp and signature

The webhook.test event has no job_id

Sume's Send test control posts a signed webhook.test body. It is not a job, and it carries no job_id, so a handler that reads event.job_id without a guard would throw on it and Sume would see a failed delivery. The route above checks event.job_id && before the duplicate set, so the test event gets a 200 and is otherwise ignored.

You can trigger the test from the Webhooks tab of the dashboard, or with POST /v1/webhooks/test-deliveries and a key with account:write.

Prove it with a signed request

Elysia keeps a Set of seen job ids in memory here. That is fine for a demo. A real service stores the event first, and uses job_id as a unique key in its own database, because Sume can deliver the same job more than once.

The next block is a Node script that signs one body three ways and prints the status of each answer. Start the receiver with SUME_COM_WEBHOOK_SIGNING_SECRET=whsec_test PORT=8000 bun index.ts first. The output should be 200, then 401, then 401.

import { createHmac } 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 url = process.argv[2] ?? "http://127.0.0.1:8000/hooks/sume";
const body = JSON.stringify({ event: "job.completed", request_id: "job_demo",
  job_id: "job_demo", status: "OK", payload: { artifacts: [] } });

async function send(label, ts, key) {
  const mac = createHmac("sha256", key).update(`${ts}.${body}`).digest("hex");
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-sume-webhook-timestamp": String(ts),
      "x-sume-webhook-signature": `sume-v1=${mac}`,
    },
    body,
  });
  console.log(label, res.status);
}

const now = Math.floor(Date.now() / 1000);
await send("right secret:   ", now, secret);
await send("wrong secret:   ", now, secret + "x");
await send("stale timestamp:", now - 900, secret);

Sources

Related posts

More in Developers

All Developers posts

Written by Sume