Koa receiver for Sume webhooks: verify with koa-bodyparser rawBody
koa-bodyparser keeps the raw string on ctx.request.rawBody. Check the sume-v1 HMAC against it, not against a re-stringified ctx.request.body, then answer 204.

In Koa, verify a Sume webhook against ctx.request.rawBody, the unparsed string that koa-bodyparser exposes next to the parsed ctx.request.body, never against JSON.stringify(ctx.request.body). The koa-bodyparser README documents ctx.request.rawBody as the raw request body (read 2026-10-04). Sume signs the exact bytes it sent, so a re-serialized object can differ in key order, spacing or number formatting and fail every time.
Sume's header carries x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>, where the hex is HMAC-SHA256 over <timestamp>.<raw_body>. See Webhooks.
What does the middleware look like?
Register the body parser first so rawBody exists, then verify in a small middleware mounted on the webhook path only.
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
const app = new Koa();
app.use(bodyParser({ enableTypes: ["json"], jsonLimit: "2mb" }));
app.use(async (ctx) => {
if (ctx.method !== "POST" || ctx.path !== "/hooks/sume") return;
const raw: string = (ctx.request as any).rawBody ?? "";
const ts = Number(ctx.get("x-sume-webhook-timestamp"));
const fresh = Number.isFinite(ts) && Math.abs(Date.now() / 1000 - ts) <= 300;
const want = Buffer.from(
"sume-v1=" + createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex"),
);
const ok = secret !== "" && fresh &&
ctx.get("x-sume-webhook-signature").split(",").some((e) => {
const got = Buffer.from(e.trim());
return got.length === want.length && timingSafeEqual(got, want);
});
if (!ok) { ctx.status = 401; return; }
// store ctx.request.body.job_id or request_id once, then work elsewhere
ctx.status = 204;
});
app.listen(3000);Which Koa details matter?
| Setting | Value | Reason |
|---|---|---|
| enableTypes | ["json"] | Sume posts application/json; skip form and text parsing |
| jsonLimit | 2mb | The default is 1mb in the README; run receipts can reach 1 MiB |
| Verify against | ctx.request.rawBody | The signed bytes, not a rebuilt string |
| Empty secret | Refuse before comparing | An empty key verifies nothing |
| Response | 204 with no body | Any 2xx counts; 10 s timeout per attempt |
What can still go wrong?
If the body parser throws on a bad payload, Koa answers 400 or 413 before your code runs, and Sume retries. Keep jsonLimit above Sume's 1 MiB receipt cap so a large legitimate delivery is not rejected by your own parser. If you also sit behind a reverse proxy, check its body limit too; the nginx client_max_body_size default of 1m is a common culprit.
During a secret rotation the signature header holds two sume-v1= entries separated by a comma, which is why the code above checks each entry.
What next?
- Dedupe on
job_idfor job events and onrequest_idfor run events. - Return before slow work; hand the event to a queue.
- Use the dashboard's Send test to confirm the path, then one real small job.
Sources
Related posts
More in Developers
- Korean karaoke captions: korean-ad and language ko on Sume
Burn Korean karaoke-style captions with style korean-ad and language ko on /v1/video-captions: one phrase at a time, the spoken word in a heavier weight.
- Workers KV jurisdictions: keep Sume job records in region
Cloudflare made Workers KV jurisdictions generally available on Oct 2, 2026. Here is how to key Sume job_id records into a region-scoped namespace.
- LangGraph custom image: keep SUME_API_KEY out of it
langgraph-cli 0.4.32 adds an --image-uri flag for self-hosted custom containers. Inject SUME_API_KEY as a runtime env var; never bake it into the image.
- LinkedIn Live fails to process: use Baseline, not High or Main
LinkedIn says a Live stream that fails to process may need the Baseline H.264 profile, because High and Main add B-frames. Which a Sume render sets.
Written by Sume