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.

5 min readSume
All posts

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?

Koa and koa-bodyparser settings for a Sume receiver, read 2026-10-04
SettingValueReason
enableTypes["json"]Sume posts application/json; skip form and text parsing
jsonLimit2mbThe default is 1mb in the README; run receipts can reach 1 MiB
Verify againstctx.request.rawBodyThe signed bytes, not a rebuilt string
Empty secretRefuse before comparingAn empty key verifies nothing
Response204 with no bodyAny 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_id for job events and on request_id for 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

All Developers posts

Written by Sume