Alerts & webhooks
Alert kinds, delivery channels, the webhook payload and how to verify its signature.
An alert watches one number and tells you when it moves. MetrikStack evaluates every enabled alert every five minutes and delivers the ones that fire to a webhook, an email address, or both.
Alerts live per site: Site → Alerts in the dashboard, or
GET /v1/sites/{id}/alerts over the API.
Alert kinds
| Kind | Fires when |
|---|---|
ai_crawler_spike | AI-crawler pageviews in the last window reach factor × the same window one week earlier |
ai_referral_spike | Visits referred by an AI assistant reach factor × the same window one week earlier |
traffic_drop | Human pageviews in the last window fall to factor × the window immediately before it |
source_stale | A source's newest event is older than max_age_minutes |
custom_query | A POST /v1/query result crosses a threshold |
The comparison window is minute-resolution and ends at the moment of evaluation, so a 60-minute window on a run at 14:03 covers 13:03 to 14:03.
The two spike kinds compare against the same window a week earlier rather than the window before it: crawler and referral traffic follows a weekly rhythm, and a week-over-week baseline does not call every Monday morning a spike.
ai_referral_spike counts visits whose referrer host is one of
chatgpt.com, chat.openai.com, perplexity.ai, www.perplexity.ai,
copilot.microsoft.com, gemini.google.com or claude.ai, the same list
the Bots & AI crawlers page reports on.
Configuration per kind
ai_crawler_spike, ai_referral_spike:
{
"window_minutes": 60, // 1 … 10080
"factor": 2.0, // > 1
"min_count": 20 // never fire below this many hits
}traffic_drop:
{
"window_minutes": 60,
"factor": 0.5, // between 0 and 1
"min_count": 20 // the previous window must have carried this much
}source_stale:
{
"max_age_minutes": 60,
"source_id": null // null watches every active source of the site
}custom_query is the general form; the four kinds above are sugar over it:
{
"query": { // a POST /v1/query body for this same site
"site_id": "…",
"metrics": ["pageviews"],
"range": { "from": "2026-09-01", "to": "2026-09-11" },
"granularity": "none" // required: an alert compares one number
},
"metric": "pageviews",
"comparator": "lt", // gt | gte | lt | lte
"threshold": 100
}min_count exists because ratios are meaningless at small numbers: without
it, a crawler that fetched one page last week and four this week would count
as a 4× spike.
Cooldown
A firing condition usually persists across several runs. cooldown_minutes
(default 60) is the shortest gap between two deliveries of the same alert;
inside it the alert is evaluated but not delivered.
Channels
{ "type": "webhook", "url": "https://hooks.example.com/analytics", "secret": "…" }
{ "type": "email", "to": "ops@example.com" }The secret is write-only: it is stored, used to sign deliveries, and never returned by the API again. Email alerts are sent by MetrikStack; there is nothing to configure beyond the address.
POST /v1/alerts/{id}/test sends a synthetic delivery to every channel of an
alert without touching its cooldown, which is the fastest way to prove an
endpoint works. Past attempts, successful or not, are listed by
GET /v1/alerts/{id}/deliveries and in the dashboard's Deliveries drawer.
Webhook payload
A delivery is a POST with Content-Type: application/json, a 10-second
timeout and two retries (after 1 s and 3 s) on a transport error or a 5xx. A
4xx is taken as your endpoint's answer and is not retried.
POST /analytics HTTP/1.1
Content-Type: application/json
X-MetrikStack-Event: alert
X-MetrikStack-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08{
"id": "0199c0de-0000-7000-8000-000000000001",
"alert_id": "0199c0de-0000-7000-8000-000000000002",
"kind": "ai_crawler_spike",
"name": "GPTBot is hammering the docs",
"site_id": "0199c0de-0000-7000-8000-000000000003",
"fired_at": "2026-09-12T14:03:00Z",
"message": "AI crawler hits are up 3.0× in the last 60 min: 120 against 40 a week ago.",
"values": { "baseline": 40, "current": 120, "factor": 2, "threshold": 80 },
"test": false
}id is the delivery id, unique per attempt. Use it to make your handler
idempotent. test is true for deliveries triggered from the test button.
Verifying the signature
X-MetrikStack-Signature is sha256= followed by the hex HMAC-SHA256 of the
raw request body using your channel secret. Read the body as bytes before
parsing it as JSON: re-serialising changes the bytes and breaks the
comparison. Always compare in constant time.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
app.post(
"/analytics",
express.raw({ type: "application/json" }),
(req, res) => {
const expected =
"sha256=" +
createHmac("sha256", process.env.METRIKSTACK_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const received = req.get("x-metrikstack-signature") ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const alert = JSON.parse(req.body.toString("utf8"));
console.log(alert.name, alert.message);
res.sendStatus(204);
}
);PHP
<?php
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('METRIKSTACK_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_METRIKSTACK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$alert = json_decode($body, true);
error_log($alert['name'] . ': ' . $alert['message']);
http_response_code(204);