Express route for a Sume webhook: raw body, verifyWebhook, 204

In Express, express.json() breaks the Sume signature check. Mount express.raw on the webhook route only, call verifyWebhook, and return a fast 2xx. TypeScript.

5 min readSume
All posts

In Express, mount express.raw({ type: "application/json" }) on the Sume webhook route only, pass req.body and req.headers to verifyWebhook from @sume-com/sdk, and return a 2xx once the event is stored. If express.json() runs first on that route, the bytes are already parsed, and the signature will not verify.

The Verifying webhooks page lists this as the first of four rules: pass the raw body, because a parsed-and-reserialized object does not verify. Key order and whitespace are part of the signed data. The other rules are that verifyWebhook is async, returns false instead of throwing, and compares in constant time.

The route

The SDK has no runtime dependencies and needs fetch and WebCrypto, so Node 18+ works. The snippet reads the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, the name Sume's own delivery worker uses, and refuses to start handling requests if it is missing.

import express from "express";
import { verifyWebhook } from "@sume-com/sdk";

const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET;
if (!secret) throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is not set");

const app = express();

// Raw bytes on this route only. Do not mount express.json() above it.
app.post("/hooks/sume", express.raw({ type: "application/json" }), async (req, res) => {
  const ok = await verifyWebhook({ body: req.body, headers: req.headers, secret });
  if (!ok) return res.status(401).send("bad signature");

  const event = JSON.parse(req.body.toString("utf8"));
  await saveEvent(event.job_id ?? event.run_id, event); // store before replying
  return res.status(204).end();
});

app.use(express.json()); // every other route may parse JSON
app.listen(3000);

What verifyWebhook accepts

The input table is from the same page, read 2026-10-09.

verifyWebhook input, as of 2026-10-09 (Verifying webhooks, SDK 0.2.0).
FieldAcceptsNotes
bodystring, ArrayBuffer, or a typed arrayRaw bytes; a Node Buffer is a typed array
headersHeaders, Map, or a plain objectCase-insensitive; Node's req.headers works
secretYour signing secretNot the API key
toleranceSecondsNumber, default 3000 skips the timestamp check

Order of middleware is the usual bug

Express runs middleware in the order you mount it. If app.use(express.json()) sits above the webhook route, the body is consumed before express.raw sees it, and req.body is a parsed object. The symptom is a valid secret and a 401 on every delivery. Mount the parser below the webhook route, as the snippet does, or give the route its own router.

Do the slow work after the reply is decided. Sume gives each attempt 10 seconds and retries a slow endpoint, so store the event, answer 204, and process in a queue. Use job_id as the dedupe key on your side, since retries and manual redelivers send the same event again.

The route also handles run webhooks. Job webhooks (job.completed, job.failed, job.canceled) carry job_id, and run webhooks (format.run.terminal and its siblings) carry run_id, but both use the same sume-v1 scheme, so one handler verifies both. Route on event after verification and do not assume a body has a particular id field. The snippet falls back from one to the other for that reason.

If checks still fail after the middleware is fixed, compare the x-sume-webhook-secret-fingerprint header with the fingerprint next to the secret in the dashboard. A mismatch means the service holds a different secret than the one that signed the delivery.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume