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.
- On the Home page, find the Insights card and click View all insights.
- Click Create.
- Pick a metric and a condition, such as Sessions, % decrease more than, 50, or pick Has anomaly.
- Keep the segment at All Users, or narrow it to a country, channel or page.
- Set Evaluation frequency to Hourly, which works for web data, not app data.
- 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.
- Fathom removed its uptime monitoring on October 1, 2023 (changelog) and sends email reports, not alerts. Its Luna assistant explains a drop only when you ask in the dashboard (help).
- Simple Analytics checks for a missing script: when a site stops getting traffic, its bot sends a test page view to see whether the script is still there (docs). The docs do not say how that alert reaches you.
- Umami sends scheduled email reports, and its docs show how to post API stats to a Slack webhook (automate reporting).
- Cloudflare Web Analytics sends a weekly summary. Cloudflare’s Traffic Anomalies alert is Enterprise-only and watches HTTP requests through its proxy, firing beyond a z-score of 3.5 against the past four hours with more than 200 requests (traffic alerts): server traffic, not visitors.
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:
- Drop: each of the last two hours sits at least 4 standard deviations under the usual, together they come to half the usual or less, and the usual is at least 30 visits. It resolves when an hour is back to 80% of usual.
- Spike: one hour sits at least 5 standard deviations over the usual, at twice the usual or more, with at least 50 extra visits. A site gets at most one spike alert a day.
- Silence: no page view for at least 60 minutes while at least 20 visits were expected, scaled to how the last day ran. Server-side events do not count, since they keep arriving when the browser tracker is what broke. If 10 or more events were refused in that time because their origin was missing or not on the site’s allowed domains, the alert says so instead.
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:
{
"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:
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.
- 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.
- 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.
- 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.
- Require persistence for drops. One weak hour happens; two in a row is a pattern. Silence is the exception.
- Give every alert a cooldown and a recovery message. The same incident announced hourly is how a channel gets muted.
- 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.
- 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:
// 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() }))
}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.mjsFor 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
- Every site that matters has spike, drop and tracking-stopped alerts; the one that earns money has a conversion alert.
- Rules compare with the same hour of the same weekday over three or four weeks.
- Each rule has a minimum volume and a cooldown, and drops need two hours in a row.
- Tracking-stopped alerts reach a person within the hour; spikes go to a shared channel.
- A test message arrived in every channel, and webhook receivers verify signatures and skip repeated ids.
- The uptime monitor stays for the server; the analytics alert covers the tracker.
