MetrikStack

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

KindFires when
ai_crawler_spikeAI-crawler pageviews in the last window reach factor × the same window one week earlier
ai_referral_spikeVisits referred by an AI assistant reach factor × the same window one week earlier
traffic_dropHuman pageviews 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

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);

On this page