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.