Slack now, email at 08:00: routing alerts from one error poller

Two channels answer two different questions. A Node poller that pings Slack for newly opened error groups and mails a morning digest of everything still unresolved.

An error store without built-in alerting isn’t missing a feature so much as leaving you the routing decision. Slack answers “is something on fire right now”; email answers “what have we been walking past all week”. Those are different questions, they want different cadences, and one cron job over Infrai’s free read routes can serve both — GET /v1/errors/groups for what’s open, GET /v1/errors/list for what just happened.

The mistake is sending both to the same place. A channel that receives every event becomes a channel nobody reads, and a daily digest that also pages you at 02:00 gets muted within a fortnight. Split by question, not by severity alone.

This has to run on your server

A browser can’t call this API at all. api.infrai.cc returns no Access-Control-Allow-Origin header, and a CORS preflight comes back 401 rather than a permissive 204, so any attempt to poll from a dashboard page dies before your key is even read.

That’s a constraint and a feature. Your API key never reaches a browser, and the alerting job is a small server-side process — a container, a Lambda on a schedule, whatever you already run.

Two channels, two questions

ChannelQueryCadenceTriggerWho reads it
Slackgroups?status=unresolvedevery 5 mingroup id not seen beforewhoever’s on today
Slack (escalated)list?level=fatalevery 5 minany event at allsame, with an @here
Email digestgroups?status=unresolveddaily 08:00always, even if emptythe team
Nothinglist?environment=stagingnobody, deliberately

The last row is doing real work. Staging errors belong in the store — you’ll want them when triaging — but routing them anywhere is how a team learns to ignore the channel.

The state file is a set, not a timestamp

The naive poller re-posts every open group on every tick. The fix people reach for first is a timestamp watermark, which works until a group goes quiet for an hour and then reopens with a last_seen_at that’s newer than your mark — you get paged for a problem you already know about.

Track group ids instead, plus the count at the moment you announced them.

// alert-poller.mjs — Node 22 ESM. Slack leg.
import { readFile, writeFile } from "node:fs/promises";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const HOOK = process.env.SLACK_WEBHOOK_URL;
const STATE = process.env.ALERT_STATE ?? "./announced.json";
if (!KEY || !HOOK) throw new Error("set INFRAI_API_KEY and SLACK_WEBHOOK_URL");

const loadState = async () => {
  try { return JSON.parse(await readFile(STATE, "utf8")); } catch { return {}; }
};

async function unresolved() {
  const res = await fetch(`${API}/v1/errors/groups?status=unresolved&limit=50`, {
    headers: { Authorization: `Bearer ${KEY}` },
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) throw new Error(`groups: HTTP ${res.status}`);
  return (await res.json()).data.groups ?? [];
}

async function slack(blocks) {
  const res = await fetch(HOOK, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ text: blocks.join("\n") }),
  });
  if (!res.ok) throw new Error(`slack: HTTP ${res.status} ${await res.text()}`);
}

const seen = await loadState();
const groups = await unresolved();
const fresh = groups.filter((g) => !seen[g.error_group_id]);

if (fresh.length) {
  await slack(fresh.map((g) => {
    const headline = g.title.split("\n")[0].slice(0, 110);
    const tag = g.level === "fatal" ? "<!here> " : "";
    return `${tag}*${headline}* — ${g.count} events in ${g.environments.join("/")} (${g.error_group_id})`;
  }));
}
for (const g of groups) seen[g.error_group_id] = { count: g.count, at: g.last_seen_at };
await writeFile(STATE, JSON.stringify(seen, null, 2), "utf8");
console.log(`${groups.length} open, ${fresh.length} announced`);

State is written after Slack accepts the post, not before. If the webhook is down the process throws, the file keeps its old contents, and the next tick tries again — losing an alert is worse than sending it twice.

What the poll actually returns

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS "https://api.infrai.cc/v1/errors/groups?status=unresolved&limit=3" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq -r '.data.groups[] | [.count, .level, (.title | split("\n")[0])] | @tsv'
19	error	voiceops worker loop error
4	error	TimeoutError: payments.charge timed out after 8000ms
3	error	PostgresError: deadlock detected

status is the only filter this route honours — environment and level are accepted and silently ignored here, so do the severity split in your own code (as the poller above does with g.level) or query GET /v1/errors/list, which does respect them. Worth flagging, because a filter that reads as applied and isn’t is exactly the bug that pages you about staging.

The 08:00 digest

Different job, different rhythm: no state, no deduplication, just the standing list ordered by how much each group is actually hurting.

// digest.mjs — runs once a day. npm i nodemailer
import nodemailer from "nodemailer";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY || !process.env.SMTP_URL) throw new Error("set INFRAI_API_KEY and SMTP_URL");

const res = await fetch("https://api.infrai.cc/v1/errors/groups?status=unresolved&limit=50", {
  headers: { Authorization: `Bearer ${KEY}` },
  signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`groups: HTTP ${res.status}`);

const groups = ((await res.json()).data.groups ?? [])
  .sort((a, b) => b.count - a.count)
  .slice(0, 10);

const rows = groups.map((g) =>
  `<tr><td>${g.count}</td><td>${g.user_count}</td><td>${g.title.split("\n")[0]}</td>` +
  `<td>${g.first_seen_at.slice(0, 10)}</td></tr>`).join("");

const html = groups.length
  ? `<p>${groups.length} unresolved error groups.</p><table border="1" cellpadding="4">
     <tr><th>events</th><th>users</th><th>title</th><th>first seen</th></tr>${rows}</table>`
  : "<p>Nothing unresolved. Enjoy it.</p>";

try {
  const mailer = nodemailer.createTransport(process.env.SMTP_URL);
  await mailer.sendMail({
    from: process.env.DIGEST_FROM ?? "alerts@example.com",
    to: process.env.DIGEST_TO ?? "team@example.com",
    subject: `Error digest — ${groups.length} open`,
    html,
  });
  console.log(`digest sent: ${groups.length} groups`);
} catch (err) {
  console.error("digest failed:", err.message);
  process.exitCode = 1;
}

user_count is in that table on purpose. Ten events from one account is a bug report; ten events from ten accounts is a decision to make before lunch. The digest ships even when the list is empty, because a mail that never arrives is indistinguishable from a job that died.

*/5 * * * * cd /srv/alerts && /usr/bin/node alert-poller.mjs >> /var/log/alerts.log 2>&1
0 8 * * * cd /srv/alerts && /usr/bin/node digest.mjs >> /var/log/alerts.log 2>&1

If keeping a box alive for a five-minute tick annoys you, the same key reaches hosted cron, queues and transactional email on this account, so the scheduler and the digest mail don’t have to be a second and third vendor.

Test the pipe without breaking anything

Waiting for a real crash to test your alerting is a bad plan, and paying to fake one is a worse one. POST /v1/errors/message writes a plain-text event, needs only a non-empty text, and is free — it doesn’t even consume the new-account trial.

curl -sS -X POST "https://api.infrai.cc/v1/errors/message" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"text":"alert-pipe check from ops runbook","level":"warning","environment":"staging"}'
{
  "ok": true,
  "data": {
    "event_id": "evt_err_5PcHaVPX9aaXdiKAJNebfkmj",
    "fingerprint": "5c01813e15994c29ca56094fffff7dbe74e695dd386a69c1db935fec01793100",
    "error_group_id": "errgrp_KUcjuk1Ue2vpJVY9DT091ZTS",
    "is_new_group": true,
    "dashboard_url": "https://infrai.cc/projects/proj_local/errors/evt_err_5PcHaVPX9aaXdiKAJNebfkmj"
  }
}

Run the poller by hand and your Slack channel should light up once — is_new_group: true on the first run, and the same group id thereafter, which is precisely the deduplication you’re testing. Because this route takes no fingerprint, repeat smoke tests with identical text land in the same group forever, so your channel doesn’t fill up with drills.

What it costs, and where it stops

Every read here is free and rate-limited, so a 5-minute poll costs nothing whatever the interval; errors.message is free too. Only errors.capture is metered, at $0.00005 per event verified 2026-07-26, with $2 of free credit on a new account. Rates on this platform drift downward and campaigns run, so check rather than trust:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("errors.")) | {id, billing: .billing.price_usd}'

Now the limitations, and they’re structural. Your detection floor is the poll interval; there’s no push subscription on this namespace, so five minutes is five minutes. There’s no acknowledgement, no on-call rotation, no per-group snooze and no escalation after N minutes of silence. If you need those, Sentry’s alert rules and Rollbar’s notification pipelines are built for it and you’d be better off buying one — this design assumes a team small enough that “the channel” and “the on-call rotation” are the same thing.

Sixty lines and a cron entry, though, is a fair price for knowing within five minutes.

References

Browse more errors developer guides