Developer10 min read

Build an email deliverability monitor in 30 minutes

A hands-on walk-through: authenticate with a Bearer key, schedule nightly placement tests, stream results with SSE, and fire a Slack alert when inbox rate drops below 80%.

A good deliverability dashboard tells you something has gone wrong before your Monday-morning newsletter goes out to 50,000 people. This article builds exactly that: a small Node.js monitor that fires a placement test on a schedule, waits for the result over Server-Sent Events, and pings Slack if the inbox rate dropped below a threshold. Thirty minutes end to end.

What you will end up with

A single monitor.js file under 40 lines that (1) triggers a placement test on every sender you care about, (2) stores historical results in SQLite, (3) posts a Slack alert when today's inbox rate drops below 80%. Scheduled with node-cron, run with node monitor.js.

What we are building

One scheduled process, three endpoints, one outbound webhook. Every night at 02:00 local time the cron job iterates over your list of sender domains. For each sender, it starts a placement test via POST /api/check, subscribes to GET /api/check/{id}/stream over SSE, waits for the final verdict, stores the result in SQLite, and — if inbox rate is under 80% — posts a summary to a Slack webhook. No frontend, no framework, just a single Node process.

Get an API key

Sign in to your Inbox Check account, open Settings → API keys, and generate one. The key starts with ic_live_. Keep it out of git. This walkthrough assumes the key lives in an environment variable called INBOX_CHECK_API_KEY.

Authentication header

Every request uses a Bearer token:

Authorization: Bearer ic_live_xxxxxxxxxxxx
Content-Type: application/json

Start a test: POST /api/check

The smallest request body starts a test for one sender:

const res = await fetch('https://check.live-direct-marketing.online/api/check', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.INBOX_CHECK_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    senderDomain: 'news.yourbrand.com',
    subject: 'Weekly digest',
    html: '<html><body>Hello from the monitor.</body></html>',
  }),
});
const { id } = await res.json();

The response contains a test id. The test itself runs asynchronously — the API fans the message out to the seed mailboxes and records where each one ended up.

Polling GET vs subscribing to SSE

Two ways to fetch results. Pick one depending on how realtime you need the answer.

GET /api/check/{id} (polling)

Cheap, simple, fine for batch monitors. Call every 15 seconds until status === 'complete'.

GET /api/check/{id}/stream (SSE)

A push stream that emits one SSE event per seed-mailbox landing, then a final complete event with the aggregate verdict. Better UX, a single connection, no polling jitter.

import EventSource from 'eventsource';

function waitForResult(id) {
  return new Promise((resolve, reject) => {
    const url = `https://check.live-direct-marketing.online/api/check/${id}/stream`;
    const es = new EventSource(url, {
      headers: { Authorization: `Bearer ${process.env.INBOX_CHECK_API_KEY}` },
    });
    es.addEventListener('complete', (e) => {
      es.close();
      resolve(JSON.parse(e.data));
    });
    es.addEventListener('error', (e) => {
      es.close();
      reject(e);
    });
  });
}

Parse the result payload

The complete event carries a JSON object shaped like:

{
  "id": "t_01H9X...",
  "status": "complete",
  "summary": {
    "inboxCount":   18,
    "spamCount":    3,
    "missingCount": 1,
    "total":        22
  },
  "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
  "spamAssassinScore": 1.2,
  "providers": [ /* per-seed detail */ ]
}

Inbox rate is simply summary.inboxCount / summary.total. Anything below 80% is your alert threshold; anything below 50% is on-fire.

Schedule with node-cron

Install the dependency and wire the schedule:

npm install node-cron eventsource better-sqlite3
import cron from 'node-cron';

const SENDERS = [
  'news.yourbrand.com',
  'tx.yourbrand.com',
  'ops.yourotherbrand.com',
];

// Every day at 02:00
cron.schedule('0 2 * * *', async () => {
  for (const domain of SENDERS) {
    try {
      await monitorOne(domain);
    } catch (err) {
      console.error(`Failed ${domain}`, err);
    }
  }
});

Store history

A one-line SQLite schema covers every question you will want to ask later: "is inbox rate trending down?", "which sender is degrading the fastest?".

import Database from 'better-sqlite3';
const db = new Database('monitor.db');
db.exec(`
  CREATE TABLE IF NOT EXISTS placements (
    id TEXT PRIMARY KEY,
    domain TEXT NOT NULL,
    ts INTEGER NOT NULL,
    inbox INTEGER,
    spam INTEGER,
    missing INTEGER,
    total INTEGER
  );
`);
const insert = db.prepare(
  'INSERT INTO placements VALUES (?, ?, ?, ?, ?, ?, ?)'
);

Postgres would look the same, swap the driver. SQLite is enough for a single-process monitor.

Post to Slack on threshold

Create an incoming webhook in Slack (Apps → Incoming Webhooks), copy the URL into SLACK_WEBHOOK, and:

async function alertSlack(domain, summary) {
  const rate = (summary.inboxCount / summary.total * 100).toFixed(1);
  const text =
    `:warning: *${domain}* inbox rate dropped to *${rate}%*\n` +
    `Inbox: ${summary.inboxCount} · Spam: ${summary.spamCount} · Missing: ${summary.missingCount}`;
  await fetch(process.env.SLACK_WEBHOOK, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text }),
  });
}

Rate limits and good citizenship

The API allows about 5 tests per minute per key on the standard plan. A nightly monitor over 50 senders takes ten minutes end to end at that rate — fine. If you want higher throughput, batch less frequently instead of pressing the limit.

  • Don't hot-loop tests every few minutes. Once a day per sender is usually enough; anything tighter is a waste of your quota.
  • Cache your seed list. list_providers rarely changes.
  • Respect 429s. Back off exponentially; the server header tells you how long to wait.
One more thing: sampling

A single test is a sample of 20+ mailboxes. That sample is representative but noisy. For serious monitoring, run two tests per sender per night and average the inbox rate — it smooths out the 5–10% random variance you would otherwise see on identical content.

Full Node.js monitor, under 40 lines

import cron from 'node-cron';
import Database from 'better-sqlite3';
import EventSource from 'eventsource';

const KEY = process.env.INBOX_CHECK_API_KEY;
const SLACK = process.env.SLACK_WEBHOOK;
const SENDERS = ['news.yourbrand.com', 'tx.yourbrand.com'];
const BASE = 'https://check.live-direct-marketing.online';

const db = new Database('monitor.db');
db.exec('CREATE TABLE IF NOT EXISTS p (id TEXT, d TEXT, ts INT, i INT, s INT, m INT, t INT)');

async function runOne(domain) {
  const r = await fetch(`${BASE}/api/check`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ senderDomain: domain, subject: 'Monitor', html: '<p>ok</p>' }),
  }).then((x) => x.json());
  const res = await new Promise((ok, bad) => {
    const es = new EventSource(`${BASE}/api/check/${r.id}/stream`,
      { headers: { Authorization: `Bearer ${KEY}` } });
    es.addEventListener('complete', (e) => { es.close(); ok(JSON.parse(e.data)); });
    es.addEventListener('error', (e) => { es.close(); bad(e); });
  });
  const s = res.summary;
  db.prepare('INSERT INTO p VALUES (?,?,?,?,?,?,?)')
    .run(res.id, domain, Date.now(), s.inboxCount, s.spamCount, s.missingCount, s.total);
  if (s.inboxCount / s.total < 0.80) {
    await fetch(SLACK, { method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: `${domain}: ${s.inboxCount}/${s.total} inbox` }) });
  }
}

cron.schedule('0 2 * * *', async () => { for (const d of SENDERS) await runOne(d); });

That is the full monitor. Save it as monitor.js, set your two env variables, run node monitor.js under pm2 or systemd, and you will get a Slack ping the next time your Gmail placement falls off.

Frequently asked questions

Can I trigger a test on deploy instead of on a schedule?

Yes. Call the POST /api/check endpoint from your CI pipeline after a template change and fail the build if inbox rate is under your threshold. The same auth, same payload. Use polling rather than SSE in CI — simpler under 60-second jobs.

Does this use up my API quota if nothing is wrong?

Yes, each run consumes one test credit per sender. For most monitors, that is under 100 tests a month — well within standard-plan quotas. Double-check your plan if you are monitoring more than 50 senders nightly.

What happens if the stream disconnects before the test finishes?

The server keeps the test running; you can reconnect to /stream with the same ID, or fall back to polling GET /api/check/{id}. Results survive a client disconnect.

Can I run this monitor serverless?

Yes, and it is a natural fit for AWS Lambda or Cloudflare Workers on a cron trigger. Swap SQLite for D1 or DynamoDB. The placement test itself is API-only — nothing stateful lives on the client.
Related reading

Check your deliverability across 20+ providers

Gmail, Outlook, Yahoo, Mail.ru, Yandex, GMX, ProtonMail and more. Real inbox screenshots, SPF/DKIM/DMARC, spam engine verdicts. Free, no signup.

Run Free Test →

Unlimited tests · 20+ seed mailboxes · Live results · No account required