invisit
All posts

By Miraç Berk15 min read

How to get alerted when website traffic drops, spikes or stops

Spike, drop and tracking-stopped alerts in GA4, Plausible, Matomo, PostHog, Fathom, Umami and invisit, with thresholds that hold and a tested Slack script.

A soft 3D line chart that dips and runs flat beside a small ringing bell and a chat bubble, in violet dithered light and shadows.

To get alerted when website traffic drops, turn on the alerts your analytics tool already has and send them somewhere you read, or run an hourly script that reads the tool’s API and posts to a Slack webhook when visits leave their usual range. Set up four alerts, not one: a spike, a drop, silence when tracking breaks, and a goal or conversion falling.

An uptime monitor does not replace them. The most common analytics failure leaves the site up and answering 200 while the tracker stops counting, and only an alert built on the analytics data sees it.

Below: what GA4, Plausible, Fathom, Simple Analytics, Matomo, PostHog, Umami and Cloudflare Web Analytics can alert on and where they send it, as of November 2026 and from each vendor’s docs; how invisit, the analytics tool we build, decides; and a script we tested against Umami 3.4.1.

Which traffic alerts do you need?

You need four, because each catches a different failure and needs a different rule.

Alert What it catches Typical causes
Spike Far more visits than usual for this hour A post on Hacker News, a newsletter, a wave of bots
Drop Visits well under usual, but not zero A lost ranking, a broken redirect, a paused campaign, a stricter consent banner
Tracking stopped No page views where there should be some The snippet dropped in a deploy, a Content Security Policy, a tag manager edit
Conversion falling Signups or sales down while traffic is fine A broken checkout, a form that errors, a payment provider outage

A spike deserves a second look: a burst from one city with 100% bounce is more likely bots than readers, as our bot traffic guide explains. A drop and a stopped tracker look alike on a chart but need opposite responses. A drop is a marketing question; silence is an engineering one, and every silent hour is data you never get back.

Why an uptime monitor misses broken tracking

An uptime monitor requests a page and checks the status code, so it reports the site up for as long as the server answers. Nearly every way tracking breaks leaves the server answering normally.

A keyword monitor gets closer. UptimeRobot’s keyword monitoring alerts when a word disappears from a page, and that word can be your snippet. It reads the raw HTML and does not run JavaScript, so it catches a snippet deleted from a template and nothing that happens in the browser.

What broke Uptime check Keyword check Alert on analytics data
Server down or returning 500 Catches it Catches it Catches it, as silence
Snippet removed from the HTML Misses it Catches it Catches it
Script blocked by a Content Security Policy Misses it Misses it Catches it
Consent banner never releases the tracker Misses it Misses it Catches it
Tag manager container edited Misses it Misses it Catches it
Analytics refuses events from a new domain Misses it Misses it Catches it
Tracker missing on one subdomain Misses it One page only Catches it, if it watches hostnames

The only proof that tracking works in real browsers is data arriving from them. So the alert that matters most, tracking stopped, has to be computed from the analytics data, by the tool or by a script reading its API.

Which analytics tools can alert on traffic, and where do alerts go?

As of November 2026, GA4, Plausible, Matomo, PostHog and invisit have traffic alerts built in, Simple Analytics checks only for a missing script, and Fathom, Umami and Cloudflare Web Analytics have none. From each vendor’s docs:

Tool Spike Drop Tracking stopped Goal or conversion Where it sends
Google Analytics 4 Yes Yes As a drop condition Yes, key events GA4’s Insights page, email
Plausible Yes Yes As a drop threshold No Email; Slack via a channel’s email address
Fathom No No No No Email reports only
Simple Analytics No No Missing-script check No Not documented
Matomo Yes Yes As a daily drop Yes, Goals reports Email, SMS, Slack
PostHog Yes Yes Yes, a “less than 1” bound Yes, funnels and trends In-app, email, Slack, Discord, Teams, webhook
Umami No No No No Email reports only
Cloudflare Web Analytics No No No No A weekly summary only
invisit Yes Yes Yes, per hostname too No Slack, Discord, ntfy, signed webhook

How each decides, and how often it looks, matters as much as whether it can:

Tool How it decides How often it checks
Google Analytics 4 A threshold you set, or “has anomaly” from a Bayesian time-series model Hourly (web data only), daily, weekly or monthly
Plausible Fixed counts: 10 current visitors, and 1 visitor in 12 hours, by default Not documented; at most two notifications per site in 24 hours
Matomo A value or percentage change against the previous period Around midnight, for the day, week or month
PostHog A threshold, a percentage change, or a detector such as Z-score or MAD Hourly on the free tier, 15 minutes from Boost, about 2 on Scale or Enterprise
invisit The same hour on the same weekday over the four previous weeks Every 5 minutes for silence, every completed hour for drops and spikes

Sources: GA4 insights and anomaly detection; Plausible notifications and Slack; Fathom features; Simple Analytics bots; Matomo custom alerts; PostHog alerts; Umami features; Cloudflare Web Analytics notifications.

How to set up a traffic drop alert in Google Analytics 4

GA4 alerts are custom insights: rules on a metric that appear on the Insights page and, with email turned on, in your inbox. You need the Analyst or Editor role, and a property holds up to 50.

  1. On the Home page, find the Insights card and click View all insights.
  2. Click Create.
  3. Pick a metric and a condition, such as Sessions, % decrease more than, 50, or pick Has anomaly.
  4. Keep the segment at All Users, or narrow it to a country, channel or page.
  5. Set Evaluation frequency to Hourly, which works for web data, not app data.
  6. Turn on Email notifications.

For a conversion alert, use a key event as the metric: purchases falling while sessions hold is the alert that pays for the rest.

Two limits apply. The anomaly model trains on the last 2 weeks for hourly checks and 90 days for daily ones (anomaly detection), so a launch inside that window shifts what counts as normal. And email is the only channel. For GA4 alerts in Slack, read the Data API yourself: the realtime report covers the last 30 minutes within seconds, while intraday data in standard properties takes 2 to 6 hours (data freshness), so check silence on the realtime endpoint.

Plausible traffic spike and drop notifications

Plausible emails two notifications, set per site under Email reports in the site settings (docs): a spike when current visitors reach your threshold, 10 by default, with top sources and pages; and a drop when unique visitors in the last 12 hours fall below your threshold, 1 by default. Each sends at most twice per site in 24 hours. For Slack, add a Slack channel’s email address as a recipient (Slack guide).

Both thresholds are fixed numbers, blind to your daily curve: a spike threshold quiet at your afternoon peak never fires at 3 a.m. The default drop rule, under 1 visitor in 12 hours, is really a tracking-stopped alert that can take 12 hours. Set it to about half your quietest 12 hours to catch real drops.

Matomo custom alerts

Matomo’s custom alerts watch any of more than 70 reports and compare a metric with the previous period, by value or percentage, then send by email, by SMS once a gateway is set up, or to Slack with an OAuth token and channel ID. They are a free plugin on Matomo On-Premise and on by default in Matomo Cloud (docs).

Matomo checks them around midnight for the period just ended, so tracking that breaks on Monday morning is reported after Monday is over. They suit goals and slow trends, such as goal conversions down 30% against the same day last week, and need something faster beside them for silence.

PostHog alerts on insights

PostHog has the most flexible alerts here. Any saved trend, funnel or SQL insight can carry a fixed bound, a percentage change, or an anomaly detector such as Z-score, MAD or Isolation Forest, which you can simulate on past data first (docs). A has value alert with a less than bound of 1 on page views catches silence, a funnel alert catches a falling conversion rate, and alerts go in-app, by email, and to Slack, Discord, Microsoft Teams or a webhook, up to five destinations each, with quiet hours built in.

The limits are plan limits: 5 alerts in total on the free tier, checks every 15 minutes from Boost up, and real-time checks about every two minutes on Scale or Enterprise.

Fathom, Simple Analytics, Umami and Cloudflare Web Analytics

None of these four alerts on traffic changes as of November 2026.

For all four, the script below fills the gap.

How invisit detects traffic spikes, drops and silence

invisit compares each hour with the same hour on the same weekday over the four previous weeks, so Monday at 9 a.m. is judged against earlier Mondays at 9 a.m. It checks for silence every 5 minutes, and for drops and spikes once each hour completes, plus 5 minutes for late events. This is what the code does as of November 2026:

The usual is the median of the past weeks, and the spread is at least the square root of the usual, so a wobble of a few visits on a quiet hour never looks significant. Drops and spikes need three weeks of history for that hour, silence two, so a new site gets no traffic alerts in its first two weeks.

We ran invisit’s detection code on synthetic, perfectly steady hourly series to find where each rule fires. Real traffic varies more, which makes drops and spikes harder to trigger, never easier.

Usual visits an hour Drop at or below, both hours Spike at or above, one hour Silence after
5 Never, silence covers it 55 4 hours
10 Never, silence covers it 60 2 hours
20 2 70 60 minutes
50 21 100 60 minutes
100 50 200 60 minutes
1,000 500 2,000 60 minutes

What an invisit tracking-stopped alert tells you

When silence fires, invisit loads the home page of the busiest hostname, or of the first allowed domain, and the alert says whether the page returns an error, does not answer, lacks the snippet, or has it while something in the browser blocks it, such as a Content Security Policy or a consent banner. It looks for the site id or /invisit.js in the HTML. When the site’s page views come from the npm package, bundled into your JavaScript, there is nothing to find there, so it checks only that the page answers and points you at your latest deploy instead.

On a site with several hostnames, the 10 busiest are watched one by one, so docs.example.com losing its snippet raises “docs.example.com stopped tracking” while the rest counts normally. If invisit’s own ingest falls more than 5 minutes behind, no site is judged that round. Drop and spike alerts name up to three sources that moved most, such as Biggest drops: google.com −201, Direct −88.

Channels, webhook payload and who can set invisit alerts up

Alerts live under Settings → Alerts, per site: Slack, Discord, ntfy (ntfy.sh or your own server) or a webhook, up to 5 channels, each saved only after a test message gets through. All three triggers are on by default and switch off one by one. Workspace members and owners can set them up, viewers cannot, and an API key or MCP connection needs the manage scope. To mute a site during a migration, send PATCH /v1/sites/{site_id}/alerts with muted_until up to 30 days ahead (REST API); muted alerts are still listed under Recent alerts.

invisit has no email or SMS channel and no conversion alerts; for a signup or sales alert, use PostHog or the script below.

A message that fails with a timeout, a network error, a 408, a 429 or a 5xx is tried again at least 2 minutes later, up to 8 attempts in all. Silence and drop alerts send a resolution message when traffic recovers, to the channels that got the opening; spikes send none. A webhook receives JSON like this drop alert, built by invisit’s own code with sample numbers:

alert.opened
{
  "type": "alert.opened",
  "incident_id": "0192f3a4-5b6c-7d8e-9f01-23456789abcd",
  "occurred_at": "2026-11-03T14:05:00.000Z",
  "site": { "id": "0199e6a1-2b3c-7d4e-8f50-6a7b8c9d0e1f", "name": "example.com" },
  "trigger": "drop",
  "severity": "warning",
  "icon": "🟠",
  "title": "example.com traffic down 77%",
  "text": "96 visits in the last 2 hours (usually ~412).\nBiggest drops: google.com −201, Direct −88",
  "url": "https://app.invisit.dev/acme/example-com",
  "details": {
    "trigger": "drop",
    "window_hours": 2,
    "actual": 96,
    "expected": 412,
    "sources": [
      { "source": "google.com", "delta": -201 },
      { "source": "", "delta": -88 },
      { "source": "news.ycombinator.com", "delta": 12 }
    ]
  }
}

type is alert.opened, alert.resolved or alert.test, and severity is critical for silence, warning for a drop, info for a spike and good for a recovery. Each delivery carries Standard Webhooks headers: webhook-id, the same on every retry, webhook-timestamp, and webhook-signature, an HMAC-SHA256 keyed with the whsec_ secret shown once when you add the channel. Check it before acting:

app/api/invisit-alerts/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'

// Standard Webhooks: v1 is base64(HMAC-SHA256 of "id.timestamp.body"), keyed with the whsec_ secret.
export function verifyInvisitWebhook(body: string, headers: Headers, secret: string): boolean {
  const id = headers.get('webhook-id')
  const timestamp = Number(headers.get('webhook-timestamp'))
  if (!id || !(Math.abs(Date.now() / 1000 - timestamp) <= 300)) return false
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest()
  return (headers.get('webhook-signature') ?? '').split(' ').some((entry) => {
    const [version, signature = ''] = entry.split(',')
    const given = Buffer.from(signature, 'base64')
    return version === 'v1' && given.length === expected.length && timingSafeEqual(given, expected)
  })
}

export async function POST(request: Request): Promise<Response> {
  const body = await request.text()
  if (!verifyInvisitWebhook(body, request.headers, process.env.INVISIT_WEBHOOK_SECRET!)) {
    return new Response(null, { status: 401 })
  }
  const alert = JSON.parse(body)
  // Retries reuse webhook-id: skip one you have already handled.
  console.log(alert.type, alert.trigger, alert.severity, alert.title)
  return new Response(null, { status: 204 })
}

We posted deliveries signed by invisit’s own code to this handler: a fresh one got 204, and a changed body, a wrong secret and one 6 minutes old each got 401. The official standardwebhooks 1.1.1 package gave the same verdicts on the fresh, changed and stale ones.

How to set alert thresholds without alert fatigue

Alert fatigue comes from rules that fire on normal variation, so the fix is a better idea of normal plus a few guards. They hold for any tool, and the script below builds in most of them.

  1. Compare with the same hour of the same weekday. Comparing with the previous hour or yesterday flags every morning ramp and every weekend. Take the median of three or four past weeks, so one odd week does not become the baseline.
  2. Set a minimum volume. Below 20 to 30 expected visits, a 50% drop is noise. On a small site, widen the window to hours or a day rather than lowering the bar.
  3. Require a relative and an absolute change. Half the usual alone fires on 3 visits against 6; a fixed count alone fires all day on a big site. Twice the usual and 50 extra visits avoids both.
  4. Require persistence for drops. One weak hour happens; two in a row is a pattern. Silence is the exception.
  5. Give every alert a cooldown and a recovery message. The same incident announced hourly is how a channel gets muted.
  6. Route by severity. Silence and conversion drops go where a person sees them within the hour, such as a phone; spikes go to a shared channel. Outside PostHog, routing is your quiet hours.
  7. Mute what you plan. Mute before a migration, and annotate it afterwards so the next person reading the chart knows why.

Before acting on a spike, run the checklist in our bot traffic guide.

How to build traffic alerts for a tool without them

For a tool with an API and no alerts, a scheduled script does the job: read the last completed hours and the same hours in the four previous weeks, compare, and post to a Slack incoming webhook, which takes JSON with a text field. This one is for Umami, self-hosted or Cloud, and needs Node 18 or later and no packages:

traffic-alert.mjs
// Checks the last completed hours of an Umami site against the same hours in the four previous
// weeks and posts to Slack on a spike, a drop or silence. Run it at ten past every hour.
import { readFile, writeFile } from 'node:fs/promises'

const API = process.env.UMAMI_API // https://api.umami.is/v1 or https://umami.example.com/api
const SITE = process.env.UMAMI_WEBSITE_ID
const SLACK = process.env.SLACK_WEBHOOK_URL
const STATE = process.env.STATE_FILE ?? 'traffic-alert-state.json'
const HOUR = 3_600_000
const WEEK = 168 * HOUR
const MIN_USUAL = 20 // stay quiet when the baseline is smaller than this many visits
const COOLDOWN = { silence: 6 * HOUR, drop: 6 * HOUR, spike: 24 * HOUR }

async function token() {
  if (process.env.UMAMI_API_KEY) return process.env.UMAMI_API_KEY // Umami Cloud
  const res = await fetch(`${API}/auth/login`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      username: process.env.UMAMI_USER,
      password: process.env.UMAMI_PASSWORD,
    }),
  })
  if (!res.ok) throw new Error(`Umami login failed: ${res.status}`)
  return (await res.json()).token
}

async function visits(auth, start, end) {
  const url = `${API}/websites/${SITE}/stats?startAt=${start}&endAt=${end}`
  const res = await fetch(url, { headers: { authorization: `Bearer ${auth}` } })
  if (!res.ok) throw new Error(`Umami stats failed: ${res.status}`)
  return (await res.json()).visits
}

// The window now, and the median of the same window one to four weeks earlier.
async function compare(auth, end, hours) {
  const start = end - hours * HOUR
  const past = await Promise.all(
    [1, 2, 3, 4].map((w) => visits(auth, start - w * WEEK, end - w * WEEK)),
  )
  const [low, b, c, high] = past.sort((x, y) => x - y)
  return { actual: await visits(auth, start, end), usual: Math.round((b + c) / 2), low, high }
}

function verdict(two, one) {
  const enough = two.usual >= MIN_USUAL
  if (enough && two.actual === 0) {
    return [
      'silence',
      `No visits in 2 hours (usually ~${two.usual}). Is the script still on the page?`,
    ]
  }
  if (enough && two.actual <= two.usual * 0.5 && two.actual < two.low) {
    return ['drop', `${two.actual} visits in 2 hours (usually ~${two.usual}).`]
  }
  if (one.actual >= one.usual * 2 && one.actual - one.usual >= 50 && one.actual > one.high) {
    return ['spike', `${one.actual} visits in the last hour (usually ~${one.usual}).`]
  }
  return null
}

const end = Math.floor(Date.now() / HOUR) * HOUR // the last completed hour
const auth = await token()
const found = verdict(await compare(auth, end, 2), await compare(auth, end, 1))
if (found) {
  const [kind, text] = found
  const state = JSON.parse(await readFile(STATE, 'utf8').catch(() => '{}'))
  if (Date.now() - (state[kind] ?? 0) < COOLDOWN[kind]) process.exit(0)
  const res = await fetch(SLACK, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ text: `Traffic ${kind}: ${text}` }),
  })
  if (!res.ok) throw new Error(`Slack refused the message: ${res.status}`)
  await writeFile(STATE, JSON.stringify({ ...state, [kind]: Date.now() }))
}
crontab
UMAMI_API=https://umami.example.com/api
UMAMI_WEBSITE_ID=your-website-id
UMAMI_USER=alerts
UMAMI_PASSWORD=a-long-password
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/XXXX
10 * * * * cd /opt/traffic-alert && node traffic-alert.mjs

For Umami Cloud, set UMAMI_API=https://api.umami.is/v1 and UMAMI_API_KEY instead of the user and password (API key). A run makes 10 stats calls, inside Cloud’s limit of 50 every 15 seconds. Cooldowns live in a state file, so run it where that file survives, such as a server’s cron, not a fresh CI runner.

We tested it against Umami 3.4.1 in Docker, with five weeks of synthetic history written into its database and a local stand-in for the Slack webhook. Normal hours and a dip to 70% sent nothing. Hours cut to 30% sent “Traffic drop: 33 visits in 2 hours (usually ~110)”, and a rerun straight after was held by the cooldown. Tripling the last hour sent a spike; emptying both hours sent silence.

For another tool, replace visits() with its call for visits between two instants:

Tool Visits between two times Docs
Plausible POST /api/v2/query, metrics: ["visits"], date_range of two ISO date-times Stats API
Fathom GET /v1/aggregations?entity=pageview&aggregates=visits with date_from and date_to Reports API
Cloudflare Web Analytics GraphQL rumPageloadEventsAdaptiveGroups; Cloudflare names it but documents no fields GraphQL datasets
Google Analytics 4 runReport with sessions, or runRealtimeReport for the last 30 minutes Realtime report

The same pattern watches conversions: Plausible’s events metric with an event:goal filter, or Fathom’s aggregations with entity=event and aggregates=conversions.

Checklist before you rely on traffic alerts