Poll-based error alerting for Node: cron, Slack and a watermark
A five-minute cron that queries Infrai's error groups, keeps a watermark so it won't repeat itself, and posts to a Slack webhook — with the filter quirks we measured.
The alerting that actually wakes someone up for a small Node backend, without standing up a second vendor account, is a cron job asking two questions every five minutes: what’s unresolved right now, and what’s turned up since the last run. No agent, no inbound webhook endpoint of your own to defend, no on-call SaaS. Infrai’s errors namespace answers both over plain REST — on the same key that runs the cron and sends the digest email downstream of the alert — and every read route is free, so the poll is a container that wakes up 288 times a day and bills nothing.
Only the write side is metered. Capture is per event; list, groups, search and group detail are free and rate-limited, which is what makes polling a reasonable design here instead of a workaround. Sentry sells the opposite trade — server-side alert rules, escalation policies, frame-level traces reconstructed from source maps — and if you want a pager that already understands rotations, buy that. This page is for teams who’d rather own sixty lines of JavaScript than a second vendor account.
Which route to poll, and what each one answers
The three read routes take different query filters, and each answers a different question. We measured all three against the live API on 2026-07-27:
| Route | Filters it applies | Returns | Good for |
|---|---|---|---|
GET /v1/errors/groups | status | grouped failures, newest activity first | ”what is still failing and unresolved” |
GET /v1/errors/list | environment, level, release | individual events, newest first | ”did anything error in prod since 09:00” |
GET /v1/errors/search | q (required) | events matching the text | ”is that specific failure back” |
Match the question to the route rather than stacking parameters onto whichever one you called first: each applies the filters it documents and disregards the rest, so ?environment=production belongs on the list route — sent to the groups route it comes back with every environment you have, which is not what an alert loop that only cares about production wants at 3am. The parameter that fails loudly is an empty q on search, which answers 400 INVALID_FILTER_SYNTAX.
So: filter events on the list route, filter open/closed on the groups route, and don’t expect either to do both.
The two queries the loop is built from
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/errors/groups?status=unresolved&limit=5" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
A trimmed version of what comes back — note last_seen_at and count, which are the two numbers an alerting decision actually turns on:
{
"ok": true,
"data": {
"groups": [
{
"error_group_id": "errgrp_DCZn637btNBwADmDraeaDVCw",
"title": "checkout: discount lookup returned null for an active promo",
"first_seen_at": "2026-07-25T16:02:10.801357Z",
"last_seen_at": "2026-07-26T00:19:29.708704Z",
"count": 3,
"level": "warning",
"is_resolved": false,
"environments": ["production"],
"releases": ["2026.07.4"]
}
],
"next_cursor": "3",
"total": 33
}
}
There’s no status field on a group, incidentally — the flag is is_resolved, even though status is what you filter by on the way in.
The event-level query is the one that respects environment and severity:
curl -sS "https://api.infrai.cc/v1/errors/list?environment=production&level=error&limit=3" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The poller
State is the whole problem. Poll without it and you re-announce the same broken checkout every five minutes until someone mutes the channel; that’s how alerting dies. A watermark file holding the last last_seen_at you’ve already reported is enough.
import { readFile, writeFile } from "node:fs/promises";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
const SLACK = process.env.SLACK_WEBHOOK_URL;
const STATE = process.env.ALERT_STATE ?? "./alert-watermark.json";
if (!KEY || !SLACK) throw new Error("set INFRAI_API_KEY and SLACK_WEBHOOK_URL");
async function readWatermark() {
try {
return JSON.parse(await readFile(STATE, "utf8"));
} catch {
return { since: new Date(Date.now() - 3600_000).toISOString() };
}
}
async function openGroups() {
const res = await fetch(`${API}/v1/errors/groups?status=unresolved&limit=25`, {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`groups query failed: HTTP ${res.status}`);
const { data } = await res.json();
return data.groups ?? [];
}
async function page(groups) {
const lines = groups.map((g) => `• *${g.title.split("\n")[0]}* — ${g.count} events, ${g.environments.join("/")}`);
const res = await fetch(SLACK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `${groups.length} error group(s) active\n${lines.join("\n")}` }),
});
if (!res.ok) console.error(`slack post failed: HTTP ${res.status}`);
}
const mark = await readWatermark();
try {
const fresh = (await openGroups()).filter((g) => g.last_seen_at > mark.since);
if (fresh.length) await page(fresh);
const newest = fresh.reduce((max, g) => (g.last_seen_at > max ? g.last_seen_at : max), mark.since);
await writeFile(STATE, JSON.stringify({ since: newest }), "utf8");
console.log(`checked at ${new Date().toISOString()}: ${fresh.length} new`);
} catch (err) {
console.error(`poll failed: ${err.message}`);
process.exitCode = 1;
}
Two details earn their place. The watermark advances only to the newest group you actually reported, so a failed Slack post doesn’t lose the alert — the next run picks it up again. And a failed poll exits non-zero, which is what lets your scheduler notice that the thing watching your app has itself stopped working.
Scheduling it
*/5 * * * * cd /srv/alerts && /usr/bin/node poll-errors.mjs >> /var/log/alerts.log 2>&1
If you’d rather not keep a box alive for a five-minute tick, the same API key reaches Infrai’s hosted cron, queue and email capabilities, so the scheduler and the digest mail live on the account you already have. That’s the practical form of the one-credential argument: the follow-on work after “detect the failure” doesn’t need a new vendor.
Test the Slack half separately before you blame the API:
curl -sS -X POST "${SLACK_WEBHOOK_URL}" \
-H "Content-Type: application/json" \
-d '{"text":"alert pipeline test — ignore"}'
Prove the whole pipe once
Push one real event, then find it:
curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"message": "alert-pipeline smoke test: synthetic failure",
"exception": "SmokeTest",
"fingerprint": "ops:alert-pipeline:smoke",
"environment": "staging",
"release": "2026.07.6"
}'
curl -sS "https://api.infrai.cc/v1/errors/search?q=smoke%20test&limit=3" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The capture response hands back error_group_id and is_new_group. A true there means nothing with that fingerprint existed before, which is the closest thing to a regression signal you get without keeping your own history.
What the loop costs
Reads are free, so the poll itself is $0.00 whatever the interval. Capture runs at $0.00005 per event, re-read on 2026-07-27, and a new account starts with $2 of free credit — the billing block scores that at 39,999 captures before you pay anything. Get today’s figure rather than trusting this paragraph:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id | startswith("errors.")) | {id, billing}'
Rates in this catalogue drift downward and discount campaigns run, so what you read back may be lower than what’s printed here. The structure is the durable part: metered writes, free reads, no seat count, no alert-rule quota.
Where this approach stops
Your alerting latency floor is the poll interval — five minutes, not five seconds. The errors namespace doesn’t support push subscriptions, so there’s no way to have it call you; polling isn’t a style choice. There’s no escalation, no acknowledgement, no schedule, and no per-group mute, and if you need any of those you’re rebuilding an on-call product badly. Sentry’s alert rules or Datadog’s monitors are the right answer at that point, and Honeybadger sits in between with simpler rules than either.
For a two-person team shipping a Node SaaS, though, a cron, a watermark file and one Slack webhook cover the actual requirement: know within five minutes that production started failing, and don’t get told twice.