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.

4 min readSume
All posts

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

Sume webhook delivery facts, read 2026-10-02 from docs.sume.com
FactValue
Signed stringtimestamp, a dot, then the raw body
Headersx-sume-webhook-timestamp and x-sume-webhook-signature
Replay window5 minutes is the suggested tolerance
AttemptsUp to 10, 30 seconds apart, 10 second timeout each
Dedupe keyjob_id (request_id on run webhooks)
Job eventsjob.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 Set is 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

All Integrations posts

Written by Sume