Webhooks and alerts
Send refusal spikes and silent integrations by email or to Slack, Discord or your SIEM, and verify the signature of SIEM deliveries.
On this page
Endpoints #
Add notification destinations in the console's Notifications page: up to ten enabled at once and 25 in total per workspace (disabled destinations do not count toward the ten).
| Type | URL | Body |
|---|---|---|
| None: sent to the workspace's owners and admins, or every member, with a verified address | Plain text with a link to the site | |
| Slack | An incoming webhook, https://hooks.slack.com/services/… | {"text": "…"} |
| Discord | A channel webhook, https://discord.com/api/webhooks/… | {"content": "…"} |
| SIEM | Any HTTPS endpoint you run | The alert as JSON, signed |
URLs must use HTTPS on port 443 without credentials, and resolve only to public addresses. Redirects are not followed. URLs and secrets are stored encrypted, and erased when you delete the destination. The page shows each destination's last delivery and recent failures, and Send test checks one destination or all of them.
When alerts fire #
An alert is sent when, in one minute, a site records more refused verifications (siteverify calls made with your secret about a token BlockAlt issued for the site; forged, malformed, empty or expired tokens, which anyone can post to your forms, are only counted in the decision log) or refused browser requests (which anyone holding the public site key can cause) than its thresholds: 20 and 1,000 by default, changed or turned off per site in the console's Rules tab. Each alert has a stable id per site and minute, so retries never produce a second alert for the same minute.
A site can also alert when its integration goes silent: it had successful verifications and then gets none for 1 to 72 hours (off by default). This alert is sent once per silence, and not while the site is paused or locked down.
Delivery #
Delivery is at least once: a failed delivery (timeout, non-2xx answer) is retried with exponential backoff, up to ten attempts and at most one hour apart. Disabled endpoints receive nothing. Answer 2xx quickly and process the alert asynchronously.
SIEM payload #
POST /blockalt HTTP/1.1
Content-Type: application/json
BlockAlt-Event-Id: blocks:3f2c9a1e-…:2026-10-31T09:41:00.000Z
BlockAlt-Signature: t=1793440925,v1=9c0e5d…(64 hex characters)
{"id":"blocks:3f2c9a1e-…:2026-10-31T09:41:00.000Z","event":"protection.blocks","siteId":"3f2c9a1e-…","siteName":"Shop","message":"BlockAlt refused 27 verifications for site "Shop" in one minute. Review the decision log.","at":"2026-10-31T09:42:05.118Z"}BlockAlt-Signaturecarries the signing timet(Unix seconds) andv1, the lowercase hex HMAC-SHA-256 of"<t>.<raw body>", keyed with the endpoint secret shown once when you created the endpoint.BlockAlt-Event-Idrepeats the alert id: store it and ignore repeats, because delivery is at least once.- Fields:
id,event(protection.blocksfor refusal spikes,integration.silentfor a silent integration,testfor a test),siteIdandsiteNamewhen the alert concerns one site, a human-readablemessageand the timeat. New event types and fields may be added.
Verifying the signature #
Compute the HMAC over t, a dot and the exact bytes you received, before parsing JSON. Compare in constant time, and refuse signatures more than five minutes away from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
export function validSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.trim().split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(now - t) > 300 || !/^[0-9a-f]{64}$/.test(parts.v1 ?? "")) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(parts.v1, "hex"));
}
// Express: app.post("/blockalt", express.raw({ type: "application/json" }), (req, res) => {
// if (!validSignature(req.body, req.get("BlockAlt-Signature"), process.env.BLOCKALT_HOOK_SECRET)) return res.sendStatus(401);
// …
// });import hashlib
import hmac
import time
def valid_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))func validSignature(rawBody []byte, header, secret string, now time.Time) bool {
var t int64
var v1 string
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch key {
case "t":
t, _ = strconv.ParseInt(value, 10, 64)
case "v1":
v1 = value
}
}
if t == 0 || math.Abs(float64(now.Unix()-t)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", t)
mac.Write(rawBody)
given, err := hex.DecodeString(v1)
return err == nil && hmac.Equal(given, mac.Sum(nil))
}The timestamp makes old deliveries useless to replay; deduplicate the rest by BlockAlt-Event-Id. If an endpoint secret leaks, delete the endpoint and create a new one.
Something unclear or missing on this page? Write to support@blockalt.click.