Sume webhook signature fails: compare the secret fingerprint

When a sume-v1 signature does not verify, one header tells you if the wrong secret signed it. A 24-line Node check reads the fingerprint and names the cause.

4 min readSume
All posts

When a Sume webhook signature will not verify, do not guess: compare the x-sume-webhook-secret-fingerprint header on the delivery with the fingerprint that GET /v1/webhooks/signing-secret returns for your key. If they differ, the delivery was signed with a different secret from the one your account now serves. If they match, the secret is right and the cause is a stale environment variable in your process or a body that was altered before you hashed it.

Neither side has to send the secret to make this comparison; the fingerprint is 12 hex characters that identify it.

Where the pieces come from

From docs.sume.com webhooks page, read 2026-10-02
ItemDetail
Secret endpointGET /v1/webhooks/signing-secret, any key with account:read
Response datascope, version, fingerprint, secret, rotation
Delivery headersx-sume-webhook-timestamp, x-sume-webhook-signature, x-sume-webhook-secret-fingerprint
During rotationSignature header has one entry per live secret, newest first
Replay windowFive minutes is the documented default

The check

checkDelivery verifies any sume-v1= entry in constant time, refuses an empty secret, and on failure names the cause. Fetch the account fingerprint once at startup with secretFingerprint and cache it. It does not check timestamp age; add your five-minute window before it.

import { createHmac, timingSafeEqual } from "node:crypto";

export async function secretFingerprint(base, key) {
  const r = await fetch(`${base}/v1/webhooks/signing-secret`, {
    headers: { authorization: `Bearer ${key}` },
  });
  if (!r.ok) throw new Error(`signing-secret ${r.status}`);
  return (await r.json()).data.fingerprint;
}

export function checkDelivery(headers, raw, secret, expectedFp) {
  if (!secret) throw new Error("empty webhook secret");
  const ts = headers["x-sume-webhook-timestamp"] ?? "";
  const want = createHmac("sha256", secret).update(`${ts}.${raw}`).digest("hex");
  const ok = String(headers["x-sume-webhook-signature"] ?? "")
    .split(",").map((p) => p.trim().replace(/^sume-v1=/, ""))
    .some((s) => s.length === want.length &&
      timingSafeEqual(Buffer.from(s), Buffer.from(want)));
  if (ok) return { ok };
  const got = headers["x-sume-webhook-secret-fingerprint"];
  return { ok, reason: got && got !== expectedFp
    ? `delivery signed with ${got}, account secret is ${expectedFp}`
    : "account secret matches: stale env var, or body/timestamp altered" };
}

What I ran

Read the code as a decision table: a correctly signed body passes; a failing body reports a stale local secret when the header fingerprint matches the account, and a different signing secret when it differs; an empty secret throws. Test it against your own endpoint before relying on it.

When to run it

  • At boot: fetch the fingerprint and log it next to your app version so every deploy records which secret it expects.
  • On every verify failure: include the delivery fingerprint and the cached account fingerprint in the log line. Never log the secret or the signature.
  • After a rotation: expect a short period where the signature header has two entries and the fingerprint header names the new secret; a verifier that checks any entry passes both.

Limits

The fingerprint cannot be recomputed from your env var with a documented formula, so this check compares two Sume-provided values and never proves your local secret directly; the signature result does that. A cached fingerprint goes stale after a rotation, so refresh it when a mismatch appears before you alert. The endpoint needs account:read, which a minimal submit-only key may lack.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume