Express raw body for webhook signatures and the 100kb limit
Mount express.raw with type application/json and a limit above 1 MiB on the Sume webhook route, then pass the raw Buffer to verifyWebhook.

To verify a webhook signature in Express, mount express.raw({ type: "application/json", limit: "2mb" }) on the webhook route only and pass the Buffer it puts on req.body to verifyWebhook from @sume-com/sdk. Both options matter for Sume: express.raw parses only application/octet-stream by default, and its default limit of 100kb is far below the 1 MiB up to which Sume inlines a run receipt in the webhook body.
Express facts come from Express's own express.raw() reference and body-parser page; Sume facts come from Verifying webhooks, Run webhooks, and Webhooks. All were read on 2026-09-27. There is no Sume middleware for Express: the receiver is a plain route. Delivery debugging is covered in Sume webhook not received?
Why does express.json() break the signature?
Sume signs every delivery with HMAC-SHA256 over <timestamp>.<raw_body>. A parsed-and-reserialized object does not verify, because key order and whitespace are part of what was signed. express.json() hands your route a parsed object on req.body, so the original bytes are gone. express.raw() keeps them: it populates req.body with a Buffer of the body.
Order matters when an app uses both. Express warns that with stacked parsers, req.body may come from a different parser, and recommends testing that req.body is a Buffer before calling buffer methods. Register the webhook route, with its own express.raw, before any app-wide express.json().
If the route has to stay behind express.json(), use that parser's verify option instead: Express calls it with buf, a Buffer of the raw request body, so you can keep those bytes for the check. Its limit also defaults to 100kb.
Which express.raw options does a Sume webhook need?
Two defaults bite. Sume sends content-type: application/json, which the default type does not match, and when the type does not match, req.body is left undefined. A body over limit gets a 413 (entity.too.large). Sume counts that as a refused attempt and retries it, and each retry of the same body meets the same limit until the attempts run out. Express's body-parser page recommends the default limit whenever possible and counts any value above it as very high, because larger payloads cost memory and response time. So raise it on this route only, and only to just above 1 MiB.
| Option | Express default | Set for Sume |
|---|---|---|
type | application/octet-stream | application/json, the content-type Sume sends |
limit | 100kb | Above 1 MiB, such as 2mb: receipts up to 1 MiB arrive inline |
What does a full Express receiver look like?
The route verifies, records, answers, and only then works. verifyWebhook takes the body as a string, an ArrayBuffer, or a typed array, so the Buffer passes as is, and it reads headers from a plain object such as Node's req.headers. It is async, returns false instead of throwing, and enforces a 300-second replay window by default.
import express from "express";
import { verifyWebhook } from "@sume-com/sdk";
const app = express();
// Webhook route first, with its own raw parser.
app.post(
"/hooks/sume",
express.raw({ type: "application/json", limit: "2mb" }),
async (req, res) => {
if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
const ok = await verifyWebhook({
body: req.body, // the raw Buffer, never a parsed object
headers: req.headers,
secret: process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!,
});
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
const fresh = await recordOnce(event.request_id, event); // insert-or-ignore
res.sendStatus(204); // answer well inside the 10 s attempt window
if (fresh) handleEvent(event).catch(console.error); // work after answering
},
);
app.use(express.json()); // the rest of the appWhat should the route answer, and when?
Any 2xx counts as delivered, and each attempt gets 10 seconds, so the route records the event, answers 204, and only then calls handleEvent. Sume makes up to 10 attempts and repeats request_id on each, which is why the insert-or-ignore comes first. Signed webhooks for video runs covers the rest of the contract. Two points bear on this route:
- A receipt over 1 MiB arrives with
payload: nulland anerror.result_url, so the body stays under the raised limit; fetch the receipt from there with your API key. - For 24 hours after a signing-secret rotation,
x-sume-webhook-signaturecarries twosume-v1=entries.verifyWebhookin@sume-com/sdk0.2.0 accepts either one; a hand-rolled check that compares the whole header fails for that window.
Which URL can Sume deliver to?
A public HTTPS one. In current code, a URL with an explicit port such as :3000 is refused as well, so put the Express app behind a public HTTPS hostname on the default port. Sume's webhook URL rules lists the other refusals, and Test Sume webhooks on localhost covers the local development loop.
Sources
Related posts
More in Integrations
- Gemini CLI MCP server: add Sume's hosted MCP
Add Sume's hosted MCP server to Gemini CLI with httpUrl and an API-key header read from your environment, then allowlist and confirm its tools.
- GitHub Actions: generate a release video with a Sume Format
Start a Sume Format run when a GitHub release is published, pass the notes as input, poll until the video is ready, and attach it to the release.
- Golang: verify a webhook signature with HMAC and hmac.Equal
Verify a Sume webhook in Go: read the body once with io.ReadAll, HMAC-SHA256 the timestamp and raw bytes, then hmac.Equal each sume-v1 entry.
- Google ADK MCP tools: connect McpToolset to Sume's server
Add Sume's hosted MCP server to a Google ADK agent with McpToolset, an API-key header, and tool_filter, and make paid tools ask for confirmation.
Written by Sume