Webhooks
Verifying signatures
Every delivery is signed with HMAC-SHA256 over the timestamp and the raw body. Check it in a few lines, in Node, Python or anything with an HMAC library.
Updated 4 September 2026
Every webhook and subscription delivery carries:
X-Seegnals-Signature: t=1767225600,v1=3f6a1d…c9e1
tis the Unix time in seconds when this attempt was sent.v1is the lower-case hex HMAC-SHA256 of the string"<t>" + "." + <raw request body>, keyed with your signing secret (thewhsec_…string as UTF-8).
Deliveries are never sent unsigned. If a workspace has no secret, the delivery is marked failed and nothing is sent.
The check
- Read the raw body bytes exactly as received. Do not parse and re-serialise first.
- Split the header on commas; take
tandv1. - Compute
HMAC-SHA256(secret, t + "." + rawBody)and compare withv1using a constant-time comparison. - Reject if
|now - t|is more than 300 seconds. Seegnals keeps no nonce store; this window is your replay protection. - Deduplicate on
X-Seegnals-Deliveryif your handler is not idempotent.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifySeegnals(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = parts.v1 ?? "";
return expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}
With Express, make sure you verify against the raw body (express.raw({ type: "application/json" })) rather than the parsed JSON.
Python
import hmac, hashlib, time
def verify_seegnals(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Rotating the secret
Settings → Integrations → Webhooks → Rotate secret generates a new whsec_…. Deliveries signed after that moment use the new secret; the old one stops verifying immediately. Update your receiver first if you cannot afford a gap, or accept both secrets for a few minutes.
Changing the webhook URL does not rotate the secret. Removing the webhook removes both the URL and the secret.