MetrikStack

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

KindFires when
ai_crawler_spikePageviews by AI crawlers and AI assistants in the last window reach factor × the same window one week earlier
ai_referral_spikePageviews by people who arrived from an AI assistant reach factor × the same window one week earlier
traffic_dropPageviews by people in the last window fall to factor × the window immediately before it
source_staleA source's newest event is older than max_age_minutes
custom_queryA POST /v1/query result crosses a threshold
new_error, error_spikeA 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);

On this page