Empty webhook secret in staging: fail closed at boot, not per request
An unset SUME_COM_WEBHOOK_SIGNING_SECRET makes an HMAC over an empty key that anyone can forge. Refuse to start, then accept either entry during a rotation.

Check the webhook secret once when the process starts and refuse to boot if it is empty. Sume signs deliveries with HMAC SHA-256 over <timestamp>.<raw_body>, and the secret lives in your environment as SUME_COM_WEBHOOK_SIGNING_SECRET. A staging deploy that forgot to set it will, in many languages, happily compute an HMAC with an empty key, and an attacker who guesses that can sign anything your endpoint accepts. A failure at boot is loud and costs nothing; a verifier that quietly works with no secret is a hole.
Why per-request checks are the weaker place
A check inside the request handler runs after you have already accepted the connection, and it is easy to write it as a conditional that falls through to processing on error. Failing at startup removes that class of bug: the service cannot take traffic in the misconfigured state.
The secret is yours, derived for your workspace, and readable from the Webhooks tab of the dashboard or from GET /v1/webhooks/signing-secret with an account:read key. Job webhooks and run webhooks share it, so one startup check covers both.
A verifier that refuses empty and accepts either entry
The factory below throws on an empty or whitespace secret, then returns a function that checks the timestamp tolerance (300 seconds by default), recomputes the signature over the raw body and compares in constant time. It walks every comma-separated entry of the header, because during a rotation the header carries one sume-v1= entry per live secret, newest first, and a verifier that compares the whole header string to one expected value fails during the 24-hour window. It runs on Node 18 or newer as an ES module and prints the refusal, then true false.
import { createHmac, timingSafeEqual } from "node:crypto";
export function makeVerifier(secret, toleranceSeconds = 300) {
if (!secret || secret.trim() === "") {
throw new Error("SUME_COM_WEBHOOK_SIGNING_SECRET is empty: refusing to start");
}
return (rawBody, timestamp, header, now = Date.now() / 1000) => {
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) return false;
const want = Buffer.from("sume-v1=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"));
return header.split(",").some((part) => {
const got = Buffer.from(part.trim());
return got.length === want.length && timingSafeEqual(got, want);
});
};
}
try { makeVerifier(process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? ""); }
catch (e) { console.log(e.message); }
const verify = makeVerifier("whsec_demo");
const ts = String(Math.floor(Date.now() / 1000));
const sig = "sume-v1=" + createHmac("sha256", "whsec_demo").update(`${ts}.{}`).digest("hex");
console.log(verify("{}", ts, sig), verify("{}", ts, "sume-v1=00"));Staging, previews and the 12-character fingerprint
Preview environments are where the empty secret appears, because someone copies the production service config and leaves the secret out. Keep the startup check identical across all environments so it does not become a production-only rule.
Every delivery also carries x-sume-webhook-secret-fingerprint, the first 12 hex characters identifying the secret that signed it, and the receipt repeats it as webhook_delivery.signing_secret_fingerprint. When signatures stop verifying, log the fingerprint header from a failing delivery and compare it with the one shown beside the secret in the dashboard; neither side has to send the secret itself. During a rotation the fingerprint header names the new secret.
| Condition | Verifier behaviour | Reason |
|---|---|---|
| Secret unset or blank | Refuse to boot | Verifier would sign over an empty key |
| Timestamp older than 300 s | Reject | Replay protection |
| Header has two sume-v1 entries | Accept if any matches | Rotation window, 24 hours |
| Fingerprint differs from yours | Investigate | Wrong environment or rotated secret |
| Unknown event name | Return 204 | Avoid retry storms |
Test the refusal
Add one unit test that constructs the verifier with an empty string and asserts it throws, and one that passes a valid delivery with a two-entry header. Both are two minutes of work. The official SDK's verifyWebhook is async and returns false instead of throwing, so wrap it the same way if you use it: check the secret yourself at startup, then call it per request on the raw body.
Rotation in practice
When you rotate with POST /v1/webhooks/signing-secret/rotate, which needs an account:write key, both secrets sign for 24 hours, so update the environment value on every receiver within that window and redeploy. A receiver still holding the old secret keeps verifying because the header lists both entries, while one holding only the new secret also verifies. After the window only the new one signs. If you hand-roll the verifier, test it with a two-entry header, since that is the case a single equality check gets wrong.
Sources
Related posts
More in Developers
- Test a webhook endpoint before go-live: a Sume CI gate (Python)
Use POST /v1/webhooks/test-deliveries to fire a signed webhook.test at your deployed URL and fail the deploy unless it answers 2xx. Python script included.
- WebVTT cue text cannot contain --> : clean Sume STT segments in Python
WebVTT forbids the arrow sequence inside cue text and wants 3-digit milliseconds. A short Python script turns Sume STT sentence segments into a valid .vtt file.
- Four avatar clips a week: Python batch, one idempotency key each
HeyGen's survey ties avatars to consistent posting. Submit four Sume avatar clips a week from one handle with week-stamped keys and queue_full handling.
- What to measure for Sume API jobs: metrics, labels and alerts
A metrics plan for code that calls the Sume API: submit outcome, queue wait, time to terminal, error code and webhook gap, with low-cardinality labels.
Written by Sume