Seegnals

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
  • t is the Unix time in seconds when this attempt was sent.
  • v1 is the lower-case hex HMAC-SHA256 of the string "<t>" + "." + <raw request body>, keyed with your signing secret (the whsec_… 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

  1. Read the raw body bytes exactly as received. Do not parse and re-serialise first.
  2. Split the header on commas; take t and v1.
  3. Compute HMAC-SHA256(secret, t + "." + rawBody) and compare with v1 using a constant-time comparison.
  4. Reject if |now - t| is more than 300 seconds. Seegnals keeps no nonce store; this window is your replay protection.
  5. Deduplicate on X-Seegnals-Delivery if 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.