Verify x-sume-webhook-signature in Node: sume-v1 HMAC, raw body

A node:crypto verifier for Sume's sume-v1 signature that refuses an empty secret, checks the 5-minute window, and accepts either entry during a rotation.

3 min readSume
All posts

Sume signs <timestamp>.<raw_body> with HMAC SHA-256 and sends the result as x-sume-webhook-signature: sume-v1=<hex> beside x-sume-webhook-timestamp. To verify in Node, recompute the digest over the raw bytes, compare it in constant time against every sume-v1= entry in the header, and reject timestamps more than five minutes old.

Two details cause most failures: parsing the JSON before hashing, and forgetting that during a rotation the header carries two comma-separated signatures. The verifier below handles both and throws if the secret is empty, because an empty key would turn the check into a formality.

The verifier

Pass the body exactly as received, as a string.

import crypto from "node:crypto";

export function verifySume({ rawBody, headers, secret, toleranceS = 300 }) {
  if (!secret) throw new Error("webhook secret is empty; refusing to verify");
  const ts = Number(headers["x-sume-webhook-timestamp"]);
  const header = String(headers["x-sume-webhook-signature"] ?? "");
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > toleranceS) return false;

  const digest = crypto
    .createHmac("sha256", secret)
    .update(ts + "." + rawBody)
    .digest("hex");
  const want = Buffer.from("sume-v1=" + digest);

  let ok = false;
  for (const part of header.split(",")) {
    const got = Buffer.from(part.trim());
    if (got.length === want.length && crypto.timingSafeEqual(got, want)) ok = true;
  }
  return ok;
}

Wiring it to a server

Read the stream into a buffer first; do not let a JSON middleware touch the bytes.

import http from "node:http";
import { verifySume } from "./verify.js";

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

http.createServer(async (req, res) => {
  const chunks = [];
  for await (const c of req) chunks.push(c);
  const rawBody = Buffer.concat(chunks).toString("utf8");
  if (!verifySume({ rawBody, headers: req.headers, secret })) {
    res.writeHead(401).end();
    return;
  }
  const ev = JSON.parse(rawBody);
  console.log(ev.event, ev.job_id); // store durably, dedupe on job_id
  res.writeHead(204).end();
}).listen(8080);

Checklist

  • Get the secret from the dashboard Webhooks tab or GET /v1/webhooks/signing-secret (needs account:read).
  • Return a fast 2xx after storing the event; Sume retries anything else.
  • On a mismatch, compare x-sume-webhook-secret-fingerprint with the fingerprint in the dashboard rather than pasting the secret anywhere.

Why constant-time comparison

A normal string comparison stops at the first differing byte. Over many guesses that timing difference can leak how much of a signature is right. crypto.timingSafeEqual compares in time that does not depend on where the bytes differ, but it throws on buffers of different length, which is why the code checks the length first. The loop compares every entry in the header rather than returning on the first match, so timing does not reveal which entry matched.

Common failures

  • Using JSON.stringify(req.body): re-serialization changes key order or spacing, and the digest no longer matches.
  • Reading Date.now() in milliseconds as seconds: the timestamp header is in seconds.
  • Comparing the whole header to one signature: this breaks during a 24-hour rotation window.
  • Logging the secret while debugging; log the fingerprint header instead.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume