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 cheapest alerting that actually wakes someone up for a small Node backend 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, 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 quietly ignores

The three read routes don’t honour the same query filters, and nothing in the response tells you a filter was dropped. We measured each against the live API in July 2026:

RouteFilters it honoursReturnsGood for
GET /v1/errors/groupsstatus onlyaggregated groups, newest activity first”what is broken and still open”
GET /v1/errors/listenvironment, level, releaseindividual events, newest first”did anything error in prod since 09:00”
GET /v1/errors/searchq (required)events matching the text”is that specific failure back”

Unknown parameters are ignored rather than rejected. Sending ?environment=production to the groups route returns every environment you have and looks like it worked — worth flagging, because an alert loop built on a silently-ignored filter pages you about staging at 3am. The one parameter that fails loudly is an empty q on search, which comes back 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, verified 2026-07-26, and a new account starts with $2 of free credit — roughly 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.

References

Browse more errors developer guides