Verify Sume job webhooks in Express: raw body, sume-v1, replay window

Verify a Sume job.completed webhook in Express with @sume-com/sdk verifyWebhook: raw body, empty secret refused, dedupe on job_id, answer 2xx fast.

5 min readSume
All posts

Mount express.raw() on the webhook route only, call verifyWebhook from @sume-com/sdk on the unparsed body, and return a fast 2xx once you have stored the event. Sume signs <timestamp>.<raw_body> with HMAC SHA-256, and a parsed-then-reserialized body will not match.

The same verifier covers job webhooks (job.completed, job.failed, job.canceled) and run webhooks, so route on the event field.

Why the raw body matters

Key order and whitespace are part of the signed bytes. If a JSON middleware ran before your handler, the original bytes are gone. In Express that means express.raw({ type: "application/json" }) on this route, mounted before any express.json().

verifyWebhook is async because it uses WebCrypto. It returns false instead of throwing for a missing header, a bad timestamp, or a wrong signature, and it rejects timestamps outside a 300-second window by default.

Headers on a Sume job webhook delivery (read 2026-10-07)
HeaderContent
x-sume-webhook-timestampUnix seconds, part of the signed string
x-sume-webhook-signaturesume-v1=<hex>, may list two entries during a rotation
x-sume-webhook-secret-fingerprintIdentifies the secret in use, safe to log

The receiver

Read the secret from SUME_COM_WEBHOOK_SIGNING_SECRET, and refuse to start without it. A verifier with an empty secret accepts forged requests.

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 required");
const seen = new Set(); // use a database table in production

const app = express();
app.post("/hooks/sume", express.raw({ type: "application/json" }), async (req, res) => {
  const body = req.body.toString("utf8");
  const ok = await verifyWebhook({ body, headers: req.headers, secret });
  if (!ok) return res.status(401).send("bad signature");
  const event = JSON.parse(body);
  if (!seen.has(event.job_id)) {
    seen.add(event.job_id);
    console.log(event.event, event.job_id);
  }
  res.status(204).end();
});
app.listen(3000);

After the 2xx

Do the slow work, such as copying the video, after you respond. Sume gives each attempt 10 seconds. Keep the job_id set in storage so a retry or a manual redeliver does not do the work twice.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume