You do not need a monitoring SaaS. If you send from one or two domains and you have a Linux box with cron on it (a VPS, a Raspberry Pi, an old MacBook that never sleeps), you can run daily deliverability checks for free. Thirty lines of Node, a sqlite file for history, and a Slack webhook that pings when inbox rate drops below a threshold. That is the whole monitor.
One cron + one Node script + one Inbox Check API key covers small teams with 1–4 sending domains. Once you cross ~5 senders or your alerting needs branching logic (different thresholds per domain, escalation, on-call), upgrade to a proper dashboard. Until then, this is the cheapest monitor you will ever build.
The pattern
The flow is boring on purpose:
- Cron triggers a Node script once a day at 02:00.
- Script hits
POST /api/checkfor each domain. - Waits for the result (polling is fine here).
- Appends a row to a local sqlite database.
- If inbox rate dipped more than X percentage points below the 7-day average, post to Slack.
- Exit. Wake up tomorrow.
Setting up cron on Linux and macOS
On Linux, crontab -e edits the per-user crontab. On macOS, launchd is technically preferred but cron still works and is simpler. Either way the syntax is identical:
# m h dom mon dow command
0 2 * * * cd /home/you/monitor && /usr/bin/node monitor.js >> /var/log/inbox-monitor.log 2>&1A few rules of thumb so the job does not haunt you:
- Absolute paths everywhere. Cron's
PATHis minimal; it will not findnodeunless you spell it out. - Redirect stdout and stderr to a log file. A silent failing cron is the worst kind of cron.
- Pick 02:00 local time, not on the hour. 00:00 and 01:00 are contested by ESP cron jobs and your placement numbers will be noisier.
The 30-line Node script
Uses inbox-check (the first-party SDK) and better-sqlite3. No other dependencies, no async iterator trickery, just a top-level await and a for loop.
// monitor.js
import { InboxCheck } from 'inbox-check';
import Database from 'better-sqlite3';
const ic = new InboxCheck(); // reads INBOX_CHECK_API_KEY
const db = new Database('monitor.db');
db.exec(`CREATE TABLE IF NOT EXISTS p
(d TEXT, ts INT, inbox INT, total INT)`);
const SENDERS = process.env.SENDERS.split(','); // comma-separated domains
const SLACK = process.env.SLACK_WEBHOOK;
for (const domain of SENDERS) {
const test = await ic.tests.create({
senderDomain: domain,
subject: 'Daily monitor',
html: '<p>ok</p>',
});
const { summary } = await ic.tests.wait(test.id);
db.prepare('INSERT INTO p VALUES (?, ?, ?, ?)')
.run(domain, Date.now(), summary.inboxCount, summary.total);
const rate = (summary.inboxCount / summary.total) * 100;
const week = db.prepare(
'SELECT AVG(CAST(inbox AS REAL) / total) * 100 AS avg FROM p WHERE d = ? AND ts > ?'
).get(domain, Date.now() - 7 * 86400_000);
if (week.avg && rate < week.avg - 10) {
await fetch(SLACK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `${domain}: ${rate.toFixed(0)}% inbox (7d avg ${week.avg.toFixed(0)}%)`,
}),
});
}
}That is the whole script. Save it, run it once by hand to verify it works, and wire the cron entry in.
Persisting results: sqlite vs CSV
Sqlite is the right default. One file, zero configuration, queryable. A CSV works too if you want to import into Google Sheets for a non-technical stakeholder, but then you have to compute aggregates in the script instead of in SQL. Not worth it. Write CSV as an export, not as the primary store.
# Export last 30 days to CSV from sqlite:
sqlite3 monitor.db <<SQL
.mode csv
.output last30.csv
SELECT datetime(ts/1000, 'unixepoch') AS when, d, inbox, total,
ROUND(100.0 * inbox / total, 1) AS rate
FROM p
WHERE ts > strftime('%s','now','-30 days') * 1000
ORDER BY ts;
SQLAlert thresholds: static vs rolling
A static threshold ("alert if below 80%") is the obvious approach and it is wrong. Some senders sit at 85% inbox rate routinely; some sit at 65% because of their audience mix. Static thresholds either false-fire on the first group or miss real regressions on the second.
A rolling threshold — compare today to the last 7 days — is better. The script above uses "fire if today is more than 10 percentage points below the 7-day average." That catches a real drop regardless of baseline.
Rolling thresholds ignore one important failure mode: slow degradation over weeks. If your domain drifts from 85% to 72% over 30 days, the 7-day rolling diff never fires. Mitigate by also running a monthly "is current 30-day avg 15+ points below the all-time avg" check. Cron can do that too; it is the same script with a different window.
Slack (or Discord, or webhook of any shape)
Any HTTP endpoint that accepts a JSON POST works. For Slack:
- Slack app directory → Incoming Webhooks → add to channel.
- Copy the URL into
SLACK_WEBHOOK. - The payload is
{ "text": "..." }. Usemrkdwnsyntax for bold and links.
Discord webhooks take the same JSON plus a username. For PagerDuty or Opsgenie, change the body shape and headers — the rest of the script is the same.
Failure modes and how to handle them
Key rotation
When you rotate the Inbox Check API key, the script will start returning 401. Cron logs it, you do not. Add a catch around each test and alert to Slack on any non-2xx:
try {
const test = await ic.tests.create({ ... });
// ...
} catch (err) {
await fetch(SLACK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `Monitor failed: ${err.message}` }),
});
}Seed drift
Seed mailboxes occasionally rotate (one provider deprecates a mailbox type; we add a new one). The test count per test can shift by ±2 seeds over time. Normalise on inbox rate, never on absolute inbox count.
Clock skew
If your box is in a container with a drifting clock, cron fires at the wrong time and your rolling averages get weird. Make sure NTP is running (timedatectl on systemd).
When you need a real dashboard
- 5+ senders. Cron still works but the Slack channel gets noisy. A dashboard lets you see all senders at once.
- Multiple teams. If marketing and growth have different thresholds and different Slack channels, a dashboard with per-sender config beats a forked script.
- Historical queries. "Show me every time Gmail inbox dropped below 70% in Q1" is painful in sqlite with a raw schema. A dashboard with a date-range picker is worth the hour of work.