Ship a Rank-Drop Alerting System (cron + Slack + SERP API)
A ranking dashboard tells you a money keyword tanked — on Thursday, when someone happens to open it. By then you have lost three days of traffic and the cause is cold. The job of an alerting system is to collapse that gap to minutes. This post builds the whole thing: a scheduler, a SERP call, drop detection that doesn't cry wolf, and a Slack message a human will actually act on.
Why a Dashboard Isn't Enough
Dashboards are pull. Incidents are push. A ranking collapse is an incident — a deploy that broke a template, a botched migration, a Google update — and incidents need to come to you. The single most valuable property of this system is not the SERP data; it is that the data finds the person who can fix it before the customer does.
The Whole System in Four Parts
Resist the urge to make this a platform. It is four small parts and nothing else:
| Part | Job | Honest minimum |
|---|---|---|
| Schedule | Run the check on a cadence | One cron line |
| Position check | Where does this URL rank now? | One SERP API call |
| State + detection | Did it drop enough to matter? | One table, a threshold |
| Alert | Tell a human, actionably | One Slack webhook POST |
1. The Position Check
One function: given a keyword and the domain you care about, return its current position. This is the same primitive a full rank tracker is built on — the alerting system is a tracker with an opinion.
async function checkPosition(keyword, domain) {
const url = new URL('https://apiserpent.com/api/search');
url.searchParams.set('q', keyword);
url.searchParams.set('engine', 'google');
url.searchParams.set('country', 'us');
url.searchParams.set('num', '100'); // need depth to see a fall out of top 10
const r = await fetch(url, { headers: { 'X-API-Key': process.env.SERPENT_KEY } });
if (!r.ok) throw new Error(`serp ${r.status}`);
const { results } = await r.json();
const hit = results.findIndex(x => new URL(x.link).hostname.endsWith(domain));
return hit === -1 ? null : hit + 1; // null = not in top 100
}
Two deliberate choices. num=100 because the alerts that matter most are the ones where you fell out of the first page entirely — a top-10 check can't see that. And null for "not found" is a real state, not an error: dropping off the radar is the most important alert this system sends.
2. Drop Detection That Survives Noise
This is the part that separates a useful alert from a muted channel. Google rankings jitter by a position or two constantly — personalization, datacenter variance, ordinary churn. Alert on every wobble and your team mutes the channel within a week, which is strictly worse than no alerting at all.
Three rules turn noise into signal:
- Threshold. Only a drop bigger than
Xpositions counts. A move from 3→4 is noise; 3→19 is an incident. - Persistence. The drop must hold across two consecutive checks. One bad reading is variance; two in a row is a trend.
- Cooldown. Once a keyword has paged you, mute that keyword for a window so one incident doesn't post twenty times.
function evaluate(prev, current, keyword) {
// prev: { pos, consecutiveDrops, alertedAt }
const DROP = 8, COOLDOWN_MS = 6 * 3600 * 1000;
const fellOff = current === null && prev.pos !== null;
const dropped = current !== null && prev.pos !== null && (current - prev.pos) >= DROP;
const isBad = fellOff || dropped;
const streak = isBad ? prev.consecutiveDrops + 1 : 0;
const cooled = !prev.alertedAt || Date.now() - prev.alertedAt > COOLDOWN_MS;
const fire = streak >= 2 && cooled; // persistence + cooldown
return { fire, streak, fellOff };
}
The streak >= 2 is the line that earns the team's trust. An alert that is right almost every time gets acted on; an alert that is wrong twice gets filtered to a folder no one reads. This is the same false-positive discipline good observability alerting lives or dies on.
An alert system's quality is measured by what people do when it fires. If the honest answer is "glance and dismiss," you built noise. Tune for trust before you tune for speed.
3. The Slack Message
A bad alert says "ranking changed." A good alert says exactly what, how far, where, and gives a one-click way to verify — so the reader can act without opening anything else first.
async function alertSlack({ keyword, from, to, urlThatRanked }) {
const drop = to === null ? 'fell out of top 100' : `#${from} → #${to}`;
await fetch(process.env.SLACK_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `:rotating_light: Rank drop: *${keyword}*`,
blocks: [
{ type: 'section', text: { type: 'mrkdwn',
text: `*Rank drop detected*\n*Keyword:* ${keyword}\n*Change:* ${drop}\n*URL:* ${urlThatRanked || 'n/a'}` } },
{ type: 'actions', elements: [
{ type: 'button', text: { type: 'plain_text', text: 'Verify on Google' },
url: `https://www.google.com/search?q=${encodeURIComponent(keyword)}` } ] }
]
})
});
}
A Slack incoming webhook is one POST — no SDK, no OAuth. Swap the URL for a PagerDuty or email endpoint and the rest of the system is unchanged; the alert sink is the most replaceable part by design.
4. The Schedule
For almost every SEO use case, twice a day beats hourly. Rankings rarely move meaningfully faster than that, and over-checking just multiplies cost and noise without buying earlier detection.
# crontab: 09:00 and 21:00 UTC
0 9,21 * * * cd /opt/rank-alert && node run.js >> /var/log/rank-alert.log 2>&1
// run.js — the loop, bounded so 500 keywords don't fire 500 calls at once
import { runPool } from './pool.js'; // see the "at scale" post
const keywords = await store.allTracked();
await runPool(keywords, async (k) => {
const pos = await checkPosition(k.keyword, k.domain);
const prev = await store.get(k.id);
const { fire, streak } = evaluate(prev, pos, k.keyword);
if (fire) await alertSlack({ keyword: k.keyword, from: prev.pos, to: pos, urlThatRanked: k.url });
await store.put(k.id, { pos, consecutiveDrops: streak, alertedAt: fire ? Date.now() : prev.alertedAt });
}, 6); // bounded concurrency, not Promise.all
The bounded pool is not optional — a naive Promise.all over a big keyword set is the exact mistake the running-at-scale post exists to prevent. Reuse that pool here verbatim.
Hardening It for Production
- Alert on the alerter. If the cron didn't run or every SERP call errored, that silence is itself an incident. A dead-man's-switch ("ping me if no run completed in 26h") is as important as the rank logic.
- Distinguish "dropped" from "couldn't check." A failed SERP call is not a rank of
null. Treating an outage as a ranking collapse is the classic false page — retry with backoff, and only trust a clean read. - Bound and cache. A weekly re-check of the same keywords is exactly the duplication the SERP cache post reclaims — intent-based TTLs apply here directly.
- Make the run resumable. Per-keyword state means a crash mid-run resumes cleanly, the same idempotency the scale post insists on.
That is the entire system: four small parts, one piece of real logic (noise-resistant detection), and a flat per-call SERP API like Serpent API doing the only job that needs an external service. Ship the simple version this week; harden it the week after.
FAQ
How do I get a Slack alert when my Google ranking drops?
Run a scheduled job — a cron entry or a hosted scheduler — that checks each tracked keyword's position via a SERP API, compares it to the last stored position, and posts to a Slack incoming webhook when the drop exceeds a threshold you set. The whole system is a scheduler, a SERP call, a small state store and one HTTP POST to Slack.
How do I stop rank alerts from false-alarming on SERP noise?
Don't alert on a single check. Google results fluctuate by a position or two constantly. Require the drop to exceed a meaningful threshold and persist across at least two consecutive checks, and add a cooldown so the same keyword can't page you repeatedly within a window. That converts noise into signal.
How often should a rank-drop monitor run?
For most SEO use cases, once or twice a day is enough — rankings rarely change meaningfully faster than that, and over-checking just adds cost and noise. High-stakes commercial terms can justify hourly checks, but pair that with persistence and cooldown logic or you'll drown the channel.
What should a good rank-drop alert message contain?
The keyword, the old and new position, the size of the drop, the URL that was ranking, and a direct link to verify. An alert that just says "ranking changed" gets ignored; an alert that says exactly what dropped, by how much, and where to look gets acted on.
Do I need a database for a rank-drop alerting system?
You need durable state, but it can be as simple as a single table or even a JSON file for a small keyword set: last position, last checked, and a consecutive-drop counter per keyword. The logic matters far more than the storage engine — start simple and grow the store only when the keyword count demands it.


