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.

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
| Item | Detail |
|---|---|
| Secret endpoint | GET /v1/webhooks/signing-secret, any key with account:read |
| Response data | scope, version, fingerprint, secret, rotation |
| Delivery headers | x-sume-webhook-timestamp, x-sume-webhook-signature, x-sume-webhook-secret-fingerprint |
| During rotation | Signature header has one entry per live secret, newest first |
| Replay window | Five 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
- Sume webhook never arrived: a sweeper that settles pending jobs
Run a small timer job that reads status for jobs still pending after ten minutes and settles them with the same guarded update your webhook route uses.
- Test a Sume webhook receiver with node:test and signed fixtures
Three node:test cases that sign their own Sume webhook bodies: fresh, rotation header, and the three rejections. Runs with node --test.
- TikTok upload chunk rules: 5 MB to 64 MB, up to 1,000 chunks
TikTok FILE_UPLOAD chunks must be 5 to 64 MB, the last up to 128 MB, 1,000 chunks max. A short planner computes the Content-Range for each PUT.
- TikTok Direct Post post_info fields: title, is_aigc, duet, stitch
A checked list of TikTok post_info fields as of October 2026, with a validator for title length in UTF-16 units, privacy level and the is_aigc AI label.
Written by Sume