Bun.serve webhook receiver for Sume: raw body, verify, dedupe
A 24-line Bun.serve receiver for Sume job webhooks: read the raw body first, verify sume-v1, dedupe on job_id, answer 204 before the work. Tested on Bun 1.4.

A Sume webhook receiver in Bun is a Bun.serve fetch handler that does four things in order: read await req.text() before any parsing, verify the sume-v1 signature over timestamp.rawbody, skip ids it has already seen, and return 204 quickly. The 24-line handler below does exactly that, refuses to start without a signing secret, and answers 401 to a bad signature.
It uses verifyWebhook from @sume-com/sdk (0.2.0), which is async and also accepts the two-signature header Sume sends for 24 hours after a secret rotation. Read verifyWebhook returns false during a Sume secret rotation if you hand-roll your own check.
Facts the handler relies on
| Fact | Value |
|---|---|
| Signed string | timestamp, a dot, then the raw body |
| Headers | x-sume-webhook-timestamp and x-sume-webhook-signature |
| Replay window | 5 minutes is the suggested tolerance |
| Attempts | Up to 10, 30 seconds apart, 10 second timeout each |
| Dedupe key | job_id (request_id on run webhooks) |
| Job events | job.completed, job.failed, job.canceled |
The receiver
Start it with SUME_COM_WEBHOOK_SIGNING_SECRET set to the secret from the dashboard Webhooks tab, then expose it over public HTTPS; Sume rejects localhost and non-HTTPS webhook URLs.
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 seen = new Set<string>(); // swap for a database unique index in production
Bun.serve({
port: 8787,
async fetch(req) {
if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
const body = await req.text(); // raw bytes first, parse after verifying
if (!(await verifyWebhook({ body, headers: req.headers, secret }))) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(body);
const id = event.job_id ?? event.request_id;
if (!id) return new Response(null, { status: 204 }); // unknown shape: ack, do not retry-storm
if (!seen.has(id)) {
seen.add(id);
queueMicrotask(() => console.log("handle", event.event, id)); // work after the 2xx
}
return new Response(null, { status: 204 });
},
});Why each line is there
Each line maps to a documented delivery rule, and each one fails in a recognizable way when it is missing: a parsed body gives a 401 on every genuine delivery, a missing id check gives double side effects, and slow work gives timeouts and repeat attempts.
req.text()first: re-serialized JSON does not match the signed bytes.- Unknown shapes get a 204. An event type you have not seen should not become a 500 and a retry storm.
- Work after the response: the delivery times out at 10 seconds and each failure burns one of 10 attempts.
- The
Setis a stand-in. It forgets on restart, so use a database unique constraint on the id.
Limits
I tested it on Bun 1.4.0 with @sume-com/sdk 0.2.0 by sending signed requests from a script: a valid delivery, the same delivery again, and one with a corrupted signature returned 204, 204, 401, and the handler ran once. That is a local test, not a delivery from Sume. The 204 only means you accepted the event; if your queue write fails after the response, nothing retries it, so persist the event before answering when loss matters, and keep status polling as the backup the docs recommend.
Sources
Related posts
More in Integrations
- Claude Code MCP whitespace warning: a pasted Sume key with a newline
Claude Code warns when an MCP header or url has leading or trailing whitespace, often a pasted token with a newline. It does not trim it. Fix a Sume entry.
- Claude Code .mcp.json: why ${ANTHROPIC_API_KEY} reads empty for Sume
Claude Code reads credential variables like ANTHROPIC_API_KEY and NPM_TOKEN as empty in a remote url or headers. Name your Sume key variable SUME_API_KEY.
- Claude Code: same MCP name in two scopes, one Sume entry, no merge
If a Sume server is defined in local and project scope, Claude Code loads one definition whole and warns. Order, no field merge, and which tools you get.
- claude -p loads project .mcp.json with no approval: Sume paid tools
In claude -p, Agent SDK and cloud sessions, Claude Code loads .mcp.json servers without asking. What that means for a committed Sume entry, and how to block it.
Written by Sume