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.

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
headersobject. Elysia lower-cases the names. - Set
set.status = 401for 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.
| Rule | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled (terminal only) |
| Signature | HMAC-SHA256 over <timestamp>.<raw_body>, header sume-v1=<hex> |
| Replay window | Reject timestamps outside about 5 minutes (300 s used here) |
| Attempts | Up to 10, a fixed 30 s apart by default, 10 s timeout each |
| Acknowledge | Any 2xx after you stored the event |
| Dedupe key | job_id |
| Send test | POST /v1/webhooks/test-deliveries (account:write), body is webhook.test |
| Redeliver | POST /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
- Price a mixed image batch first: endpoint pricing in Python
Read cost_usd from each Sume image endpoint and multiply by the count. 200 Grok, 50 Seedream 4.5 and 100 Flux 2 Pro images should total $11.25 before you spend.
- Fade in and out on a Timeline render: output fade seconds 0 to 5
Set output.fade_in_seconds and fade_out_seconds (0 to 5 s, sum within the render length). The music bed has its own fade_out_seconds, up to 10.
- Fast-cut Shorts in Timeline: eight chained fades, then a hard cut
Timeline refuses more than 8 adjacent fades with too_many_chained_transitions. Transitions must be 1 s or less and half the shorter neighbour. How to plan cuts.
- FastMCP 4 client OAuth with Sume: request mcp:read, add write later
Use FastMCP's OAuth helper in Python to sign in to Sume's hosted MCP with mcp:read first, then ask for mcp:write only for the script that spends.
Written by Sume