verifyWebhook returns false during a Sume secret rotation

The npm build of @sume-com/sdk 0.2.0 compares the signature header for equality, so rotation deliveries with two signatures fail. A 21-line fix.

5 min readSume
All posts

If you installed @sume-com/sdk@0.2.0 from npm and call verifyWebhook, it will return false for every delivery during the 24 hours after you rotate your webhook signing secret. During that window Sume sends two signatures in one header, and the npm build compares the whole header to a single expected value. A verifier that checks each comma-separated entry, like the 21-line one below, passes both.

The Sume docs say verifyWebhook in 0.2.0 already handles the multi-signature header, and the source in the repository does split on commas. The registry tells a different story: npm lists 0.2.0 as published on 2026-08-02, and the code in that tarball does an exact string compare. I ran both against the same inputs on 2026-10-02, so treat the tarball as the thing that runs in your build.

What I checked

I installed the package from npm, signed a body with a test secret using the documented scheme (HMAC-SHA256 over timestamp.rawbody), and called verifyWebhook with a header of the form sume-v1=<new>,sume-v1=<old>. The check is a plain string compare, so the runtime does not change the result.

verifyWebhook on the npm tarball for @sume-com/sdk 0.2.0, tested 2026-10-02
Header sentnpm 0.2.0Verifier below
One valid signaturetruetrue
New and old secret, your secret secondfalsetrue
Your secret first, old secondfalsetrue
Signature from a different secretfalsefalse
Empty secretfalsefalse

A verifier that accepts rotation

Pass the raw body string, not a parsed object, and the request Headers. It uses WebCrypto only, so it runs unchanged on Node 20+, Bun, Deno and Workers, and it refuses an empty secret.

const enc = new TextEncoder();

export async function verifySume({ body, headers, secret, tolerance = 300 }) {
  if (!secret) return false; // never verify against an empty secret
  const ts = Number(headers.get("x-sume-webhook-timestamp"));
  const header = headers.get("x-sume-webhook-signature") ?? "";
  if (!Number.isFinite(ts) || !header) return false;
  if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
  const key = await crypto.subtle.importKey(
    "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(`${ts}.${body}`)));
  const expected = "sume-v1=" + Array.from(mac, (b) => b.toString(16).padStart(2, "0")).join("");
  let ok = false;
  for (const part of header.split(",")) {
    const got = part.trim();
    let diff = got.length ^ expected.length;
    for (let i = 0; i < expected.length; i++) diff |= (got.charCodeAt(i) || 0) ^ expected.charCodeAt(i);
    if (diff === 0) ok = true; // check every entry, no early exit
  }
  return ok;
}

Where this bites

The failure is silent from your side: you rotate, your endpoint starts answering 401 to genuine deliveries, and Sume retries each one up to 10 times at fixed 30-second gaps. A 401 is not a bug in your route; it is the verifier. The delivery receipt also carries x-sume-webhook-secret-fingerprint, and comparing it with the fingerprint beside the secret in the dashboard tells you whether you hold the new secret.

Two safe orders for a rotation: swap in a multi-entry verifier first, then rotate; or rotate and accept that the first 24 hours of deliveries need the redeliver endpoint (POST /v1/jobs/{job_id}/webhook/redeliver) once you have fixed the receiver.

Limits

This post covers a mismatch between the published tarball and the repository on the date shown. A newer npm release may fix it; check the installed version's dist/verify-webhook.js for a comma split before dropping the local file. The local verifier checks the sume-v1= scheme only, so a future scheme version would need a code change.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume