A delivery dashboard from polled events: sent, delivered, bounced

Collect per-message email events into your own store with Node 22, then compute delivery rate, bounce rate and time-to-delivered for a small SaaS dashboard.

A deliverability dashboard is a collector plus four numbers. The collector walks recent messages, pulls each one’s event timeline, and writes rows you own; the numbers are delivery rate, bounce rate, complaint rate and time-to-delivered. Infrai gives you both halves as free reads — GET /v1/email/list for the message inventory and GET /v1/email/event/list for the timeline — with no webhook receiver to run.

The awkward part, and we may as well put it first: there’s no account-wide event stream. Every event query on the Infrai surface is scoped to a single message_id, so the collector is a fan-out, not a tail. That shapes the whole design.

The four numbers, and where each comes from

MetricNumerator / denominatorSourceRefreshAlert at
Delivery ratedelivered / accepted_recipientsevent type per message5 minbelow 97%
Bounce ratebounced / acceptedevent type per message5 minabove 2%
Complaint ratecomplained / deliveredevent type per messagehourlyabove 0.1%
Time-to-delivereddelivered.atqueued.attwo event rowshourly, p50 and p95p95 above 120s
Suppressed-at-sendsuppressed_recipients lengthsend responseper sendany spike

That last row is the one dashboards usually miss. A message the platform refused to send because the address was already suppressed never enters the delivery funnel at all, so counting it as a failed delivery makes your rate look worse than reality — and not counting it anywhere makes a broken audience list invisible.

Pick your denominator once and write it down.

Listing what to collect

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/email/list?limit=100" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac", "state": "sent", "channel": "email", "to": "ops@example.com", "vendor": "resend", "created_at": 1785025802.3789403 },
      { "message_id": "msg_Hj8VfkrwDby9qgHpjKB6YbPc", "state": "sent", "channel": "email", "to": "billing@example.com", "vendor": "resend", "created_at": 1785006793.713695 }
    ],
    "next_cursor": null,
    "count": 2
  }
}

Two things about that payload. created_at is epoch seconds as a float, not ISO-8601, so multiply by 1000 before it meets a Date. And in our testing a state=bounced query parameter came back with the same unfiltered page as a bare request — filter client-side rather than trusting a server-side filter you haven’t proved.

The collector

Node 22 ships node:sqlite, which makes a single-file store a zero-dependency affair. Swap it for Postgres when the dashboard outgrows one box.

import { DatabaseSync } from "node:sqlite";
import process from "node:process";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

const db = new DatabaseSync(process.env.EMAIL_DB ?? "./email-events.db");
db.exec(`CREATE TABLE IF NOT EXISTS email_event (
  message_id TEXT NOT NULL,
  type       TEXT NOT NULL,
  recipient  TEXT,
  at         TEXT NOT NULL,
  PRIMARY KEY (message_id, type, recipient, at)
)`);

async function read(path) {
  const res = await fetch(`${API}${path}`, {
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
  });
  const payload = await res.json().catch(() => ({}));
  if (!res.ok || payload.ok === false) {
    const err = payload.error ?? { code: `HTTP_${res.status}`, message: res.statusText };
    throw new Error(`${path} -> ${err.code}: ${err.message}`);
  }
  return payload.data;
}

const insert = db.prepare(
  "INSERT OR IGNORE INTO email_event (message_id, type, recipient, at) VALUES (?, ?, ?, ?)",
);

const inventory = await read("/v1/email/list?limit=100");
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const recent = (inventory.items ?? []).filter((m) => m.created_at * 1000 >= cutoff);

let rows = 0;
for (const message of recent) {
  const timeline = await read(`/v1/email/event/list?message_id=${message.message_id}&limit=100`);
  for (const event of timeline.items ?? []) {
    insert.run(message.message_id, event.type, event.recipient ?? "", event.at);
    rows += 1;
  }
  await new Promise((r) => setTimeout(r, 120));
}

console.log(`collected ${rows} event rows from ${recent.length} messages`);

The 120ms pause between messages is deliberate. These reads are free but rate-limited, and a tight loop over a day’s sends is exactly the shape of traffic that trips a limiter — pacing the fan-out costs you nothing on a 5-minute schedule and keeps the collector boring.

Turning rows into the four numbers

Once events are local, the metrics are SQL rather than API calls, which means your dashboard renders without touching the network.

WITH per_message AS (
  SELECT message_id,
         MAX(CASE WHEN type = 'delivered'  THEN 1 ELSE 0 END) AS delivered,
         MAX(CASE WHEN type = 'bounced'    THEN 1 ELSE 0 END) AS bounced,
         MAX(CASE WHEN type = 'complained' THEN 1 ELSE 0 END) AS complained,
         MIN(CASE WHEN type = 'queued'    THEN at END)        AS queued_at,
         MIN(CASE WHEN type = 'delivered' THEN at END)        AS delivered_at
  FROM email_event
  GROUP BY message_id
)
SELECT COUNT(*)                                   AS messages,
       ROUND(100.0 * SUM(delivered)  / COUNT(*), 2) AS delivery_rate_pct,
       ROUND(100.0 * SUM(bounced)    / COUNT(*), 2) AS bounce_rate_pct,
       ROUND(100.0 * SUM(complained) / COUNT(*), 2) AS complaint_rate_pct
FROM per_message;

Serve that from a small handler and you have a dashboard endpoint:

import { DatabaseSync } from "node:sqlite";
import { createServer } from "node:http";
import process from "node:process";

const db = new DatabaseSync(process.env.EMAIL_DB ?? "./email-events.db");
const summary = db.prepare(`
  SELECT type, COUNT(DISTINCT message_id) AS n
  FROM email_event
  WHERE at >= datetime('now', '-1 day')
  GROUP BY type
`);

const server = createServer((req, res) => {
  if (req.url !== "/api/deliverability") {
    res.writeHead(404).end();
    return;
  }
  try {
    const counts = Object.fromEntries(summary.all().map((r) => [r.type, r.n]));
    const accepted = counts.sent ?? counts.queued ?? 0;
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify({
      window: "24h",
      accepted,
      delivery_rate: accepted ? (counts.delivered ?? 0) / accepted : null,
      bounce_rate: accepted ? (counts.bounced ?? 0) / accepted : null,
      counts,
    }));
  } catch (err) {
    res.writeHead(500, { "content-type": "application/json" });
    res.end(JSON.stringify({ error: String(err) }));
  }
});

server.listen(Number(process.env.PORT ?? 3000), () => {
  console.log("deliverability endpoint on /api/deliverability");
});

Spot-checking one message

When a customer says the receipt never arrived, the dashboard isn’t the tool — the timeline is.

curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_jiAQ671ekGVqfGXj1LL27Gac" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "items": [
      { "type": "sent", "at": "2026-07-26T00:30:02.301347Z", "recipient": "ops@example.com", "message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac", "meta": { "vendor_message_id": "7df213d7-fa5d-4ceb-88eb-5ce0198103a6" } },
      { "type": "queued", "at": "2026-07-26T00:30:02.284184Z", "recipient": "ops@example.com", "message_id": "msg_jiAQ671ekGVqfGXj1LL27Gac", "meta": { "vendor": "resend" } }
    ],
    "next_cursor": null,
    "count": 2
  }
}

A sent with no delivered after it usually means the receiving server is still deciding. An unknown id answers EMAIL_NOT_FOUND, which nine times out of ten means staging and production are pointed at different accounts.

What running this costs

Nothing, apart from the sends themselves. Message listing, event listing and message reads are free and rate-limited; only POST /v1/email/send is metered, at $0.000115 per recipient, verified 2026-07-26, with $2 of new-account credit worth roughly 17,391 messages. Collecting every event for every message all day adds zero to the invoice, which is why polling at 5-minute granularity is a reasonable default rather than an extravagance. Rates in this market move downward over time, so check today’s:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print(next(c['billing'] for c in d['capabilities'] if c['id']=='email.send'))"

When to buy the dashboard instead of building it

SendGrid ships a hosted stats UI and an aggregate statistics API, so if you want charts today and don’t care where they’re rendered, that’s a shorter path than the collector above. Postmark’s message streams give you a searchable activity feed with the full SMTP response text attached, which beats a home-grown table when support engineers — not analysts — are the main audience.

The trade-off with building it yourself is real: you own the collector, the storage and the retention policy. What you get back is that the numbers live in your database next to tenant ids, so “which customer is generating our bounces” is a join rather than an export. And the cron entry that runs the collector, the store it writes to and the alert that fires on a threshold are all on the same key as the sends — the second question doesn’t need a second vendor.

The honest limitation stays the one from the top: no push, no global event feed, so freshness is your poll interval and fan-out cost grows with volume. Above a few hundred thousand messages a day you’d be better off with a provider that streams events to you.

References

Browse more email developer guides