Alert webhooks
A webhook channel (Pro and Agency) sends each alert as an HTTPS POST with a JSON body. Redirects aren't followed, and anything other than a 2xx response is retried a few times.
Payload
{
"id": "ae_01J9Z…", // the alert; firing and resolved share it
"rule": "site_down", // site_down, endpoint_unreachable, status_down, error_spike, traffic_drop, metric_threshold
"site": "site_01J9…", // or null
"state": "firing", // firing, resolved, or test
"summary": "Down: HTTP 500",
"url": "https://app…/w/…/sites/…", // the dashboard or incident
"at": "2026-09-26T12:00:00.000Z"
}A reminder for an alert that's still firing after 6 hours is sent again with "state": "firing".
Signature
Every request has a header Mosaicdeck-Signature: t=<unix seconds>,v1=<hex>. It covers the body, so a request can't be altered in transit:
v1 = HMAC_SHA256(secret, "<t>.POST.<path-with-query>.<hex sha256(raw body)>")The secret (whs_…) is shown once, when you create the channel. Check the signature against the raw body before parsing it, reject timestamps more than 5 minutes old, and compare in constant time.
Test vector
- secret
whs_test_3c8e1f5a9b2d4e6f7a8b9c0d1e2f3a4b,t = 1759000000, path/hooks/sitedeck - body
{"id":"ae_01J9Z","rule":"site_down","state":"firing"} - body sha256
8116b86a6d1ee07784855341a8b10b4a4c1f3d49e6284b8c5b9d3243454d783a v1 = a2b19d229a88960c487cbb054ced7af7a6adce1b0ddeb40c06c5496f82a545a6
Node.js
import crypto from "node:crypto";
// secret: the whs_… value shown once when you created the channel
export function verify(req, rawBody, secret) {
const header = req.headers["mosaicdeck-signature"] ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // 5-minute window
const url = new URL(req.url, "https://placeholder"); // path + query as received
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
const expected = crypto.createHmac("sha256", secret)
.update(`${t}.POST.${url.pathname}${url.search}.${bodyHash}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python
import hashlib, hmac, time
def verify(header: str, path_with_query: str, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t_raw = parts.get("t", "")
if not t_raw.isdigit(): # malformed header: reject, don't raise
return False
t = int(t_raw)
if abs(time.time() - t) > 300:
return False
body_hash = hashlib.sha256(raw_body).hexdigest()
msg = f"{t}.POST.{path_with_query}.{body_hash}".encode()
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Slack and Discord
Slack and Discord channels take an incoming-webhook URL and get a short text message with a link to the dashboard. Those URLs are credentials: Mosaicdeck stores them encrypted and only ever shows their host.