Alerts & webhooks
Set up MetrikStack alerts for AI crawler spikes, traffic drops and stale sources, delivered by email or a signed webhook you can verify.
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 | Pageviews by AI crawlers and AI assistants in the last window reach factor × the same window one week earlier |
ai_referral_spike | Pageviews by people who arrived from an AI assistant reach factor × the same window one week earlier |
traffic_drop | Pageviews by people 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 |
new_error, error_spike | A new or regressed error issue, or a jump in errors. See Errors |
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 AI spike kinds compare against the same window a week earlier instead of the window before it. Crawler and referral traffic follows a weekly rhythm, and comparing week over week keeps every Monday morning from looking like a spike.
ai_referral_spike counts people in the ai channel: a visit that started
from an AI assistant's referrer, or from a link an assistant tagged with
utm_source (ChatGPT often sends no referrer). This is the same traffic the
dashboard's AI visibility page reports as people sent by AI; see
AI crawlers and bots.
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, and the four kinds above are shortcuts
for common versions of 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 is there because ratios mean little 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 and used to sign deliveries, and the API never returns it. Email alerts are sent by MetrikStack, so the address is all you configure.
POST /v1/alerts/{id}/test sends a synthetic delivery to every channel of an
alert without touching its cooldown. Use it to check that 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);AI crawlers and bots
How MetrikStack sorts traffic into people, AI assistants, AI crawlers, search engines and other bots, and checks each bot's IP against its operator's list.
Errors
Lightweight error tracking for the browser and Node that groups uncaught exceptions into issues, without session replay, cookies or request bodies.