Skip to content
All posts

· 7 min read

Health endpoints that tell you which part broke

One /health that returns 200 tells you the web process is alive. It will keep saying so while payments fail, the worker is dead and no email has sent for six hours. Here is the shape that catches those.

Eduard PANTAZIUptimeGuides

Most monitoring setups can answer "is the site up". Very few can answer "which part of it isn't", which is the question you actually have at 2am, and the question your users are asking on the status page.

The fix is not a better monitoring tool. It is a handful of endpoints in your own app that know what healthy means, because your app is the only thing that does.

Why one aggregate endpoint is not enough

Uptime checkers — ours included — send a request and compare the status code. They do not parse your JSON. That is worth knowing before you design anything, because it means a single endpoint returning

{ "db": true, "payments": false, "worker": true }

with a 200 is invisible to every checker pointed at it. And if you make that endpoint 503 whenever any module is unhealthy, you get the opposite problem: your status page has one row, it goes red, and it does not say why.

So: one endpoint per module, each returning its own status code. One monitor per endpoint. Your status page then has a row per module, named after the thing your users care about, with its own uptime history.

GET /api/health          -> 200 / 503   can this process serve at all
GET /api/health/worker   -> 200 / 503   is anything draining the queues
GET /api/health/payments -> 200 / 503   can we still take money
GET /api/health/email    -> 200 / 503   is mail actually going out
GET /api/health/auth     -> 200 / 503   can people sign in

Keep the JSON body too. Nobody's checker reads it, but you will, the moment one of them goes red.

The rule that makes these cheap: derive, don't perform

The naive version of a payments check calls your payment provider's API. The naive email check sends an email. Do neither.

A check that performs the action runs every sixty seconds forever. It costs money, it burns rate limit, it fills someone's inbox, and — worst of all — it couples your status page to your provider's, so their blip becomes your red bar.

Instead, derive health from work that already happened:

  • Payments are healthy if you processed a webhook recently, or if you have not seen a burst of failures.
  • Email is healthy if the send queue is draining and the failure count is flat.
  • The worker is healthy if it said so recently.

This is the difference between a check that costs nothing and scales to every module, and one you quietly disable in three weeks because of the bill.

If your health endpoint can change anything — charge a card, send a message, write a row that matters — it is not a health endpoint, it is a synthetic transaction. Those are useful and they belong on a much slower schedule, in a staging account, run deliberately. Not every minute against production.

The worker: the failure nobody notices

Start here, because it is the quietest one.

Your web app cannot tell whether your background worker is alive by looking at itself. The database answers, the cache answers, the port serves, /health says fine — while no scheduled job runs, no email sends and no queue drains, because the process that does that died an hour ago.

The trick is a heartbeat with a TTL. The worker writes a key on a timer; the key expires on its own. Nothing has to notice the worker went away, because absence is the signal:

// in the worker, on a timer
setInterval(() => {
redis.set("worker:heartbeat", Date.now().toString(), "EX", 150);
}, 30_000);
// GET /api/health/worker
const lastBeat = await redis.get("worker:heartbeat");
const alive = lastBeat !== null;

return Response.json(
{ status: alive ? "ok" : "degraded", lastSeenSeconds: age(lastBeat) },
{ status: alive ? 200 : 503 },
);

Make the TTL several intervals long — ours writes every 30 seconds and expires after 150. A slow tick, a deploy restart or a two-second Redis blip should not page anyone. It only has to be shorter than the time you would want to go on not knowing.

One subtlety worth copying. If Redis itself is unreachable, do not report the worker as dead. You do not know that. Report unknown, and let the cache's own check be the thing that goes red:

if (!redisReachable) {
return Response.json(
{ status: "unknown", reason: "cache unreachable" },
{ status: 503 },
);
}

A monitoring system that confidently reports the wrong cause is worse than one that admits it cannot tell, because you will spend the first ten minutes of the incident debugging the wrong thing.

Payments

What you want to know is "could a customer pay us right now", and you cannot literally test that. Two derived signals get close:

Webhook freshness. If you normally process payment webhooks several times a day, a long silence is suspicious. Store the timestamp of the last successfully handled event and compare it against a threshold generous enough to survive a quiet night.

Configuration sanity. A surprising share of payment outages are not the provider — they are a key rotated in the dashboard and not in the deploy, or live credentials against a test product. That check is free and instant:

// GET /api/health/payments
const lastEvent = await db.webhookEvent.findFirst({ orderBy: { processedAt: "desc" } });
const ageHours = lastEvent ? hoursSince(lastEvent.processedAt) : Infinity;

const configured = Boolean(process.env.PAYMENTS_API_KEY && process.env.PAYMENTS_WEBHOOK_SECRET);
const keyModeMatchesProductMode = /* test key with test product, live with live */ true;

const healthy = configured && keyModeMatchesProductMode && ageHours < QUIET_HOURS_THRESHOLD;

Set the silence threshold from your actual traffic, and be honest that at low volume it is a weak signal — at two payments a week it tells you nothing, and you should drop that half and keep the config check.

Email

Same shape. Do not send a test message; look at the queue.

// GET /api/health/email
const [waiting, failed] = await Promise.all([
emailQueue.getWaitingCount(),
emailQueue.getFailedCount(),
]);

// Waiting is fine — draining is the point. A backlog that is not moving is not.
const healthy = waiting < BACKLOG_LIMIT && failed < FAILED_LIMIT;

The failure this catches is the expensive one: your provider starts rejecting sends, your queue retries and gives up, and nobody finds out until a customer mentions they never got a password reset. A rising failed count is visible immediately and costs one Redis call to read.

Auth

Check the machinery, not the round trip. You are not going to complete an OAuth flow every minute, and you should not try.

What is worth asserting: the session store is readable, the secret is present, and each provider you claim to support is actually configured. That last one is the classic post-deploy failure — an environment variable missing on one host, and the Google button 500s for everyone while your front page is perfectly fine.

// GET /api/health/auth
const storeOk = await db.session.count().then(() => true).catch(() => false);
const secretOk = Boolean(process.env.AUTH_SECRET);
const providersOk = ["GOOGLE", "GITHUB"].every(
(p) => process.env[`AUTH_${p}_ID`] && process.env[`AUTH_${p}_SECRET`],
);

Marketing pages

Monitor them — they are your storefront and they do go down — but never let them stand in for the application. A cached marketing page will happily return 200 for hours after your database has fallen over. It is the check most likely to be green during your worst outage.

Give it its own monitor and its own row, and read it as exactly what it is: "can strangers reach the site", not "does the product work".

Which endpoint the aggregate should cover

Here is the part that is easy to get wrong, and we got it wrong first.

It is tempting to make /api/health the union of every module. Do not — at least not if anything automated consumes it. Our deploy script treats a 503 from /api/health as a failed deploy and rolls back. If the worker were folded into that response, a worker that was already down would roll back a perfectly good web deploy, turning one outage into two.

So the aggregate answers one narrow question — can this process serve requests — and covers only what that needs: the database, the cache, and the commit it is running. Everything else gets its own endpoint and its own monitor.

Endpoint Answers Consumers
/api/health Can this process serve? Load balancer, deploy script, status page
/api/health/worker Is anything draining the queues? Status page, alerts
/api/health/payments Can we still take money? Status page, alerts
/api/health/email Is mail going out? Status page, alerts
/api/health/auth Can people sign in? Status page, alerts
Marketing URL Can strangers reach the site? Status page

Reporting the deployed commit in the aggregate is worth the two lines. "The port answers" only proves something is running; comparing the commit against the one you just built is what proves the deploy actually took, rather than your process manager having quietly kept the old build alive.

Things that will bite you

Return Cache-Control: no-store. A cached health response is a lie with a timestamp on it.

Time out every dependency. A hung database should make the check fail in three seconds, not make the check hang too. Race each dependency against a timer and treat "did not answer" as unhealthy.

Keep the body boring. These are public URLs. A status code, a couple of booleans, an age in seconds. No hostnames, no connection strings, no queue internals, no customer data — anything more is free reconnaissance. If you want detail, put the detail behind a token and leave the status code public.

Do not alert on all of them equally. Payments and auth are worth waking up for. A marketing page is not.

Wiring it to a status page

One monitor per endpoint, and — this matters more than it sounds — name the monitor after the thing your users understand. "Payments", not "api-health-payments". That name is what appears on your public status page, and during an incident it is the whole message.

On FeedFast the Free plan covers one monitor, which forces the useful question of which single request best answers "is the product working" — usually the aggregate. Pro allows ten, which is roughly one per module with room left over, and a five-minute interval drops to one minute.

Incidents open only after two consecutive failures, so a single blip during a provider wobble does not page you. Announce planned work as scheduled maintenance and the checks keep running and recording while incidents stay closed — which matters here, because module endpoints are exactly the ones that go red during a migration you chose to run.

The broader practice — intervals, blips, what to do during an incident — is in monitoring your endpoints. This post is only about giving those checks something worth pointing at.

The two endpoints quoted from our own app are real: /api/health reports the database, the cache and the running commit, and /api/health/worker reads a heartbeat key with a 150-second TTL written every 30 seconds. The payments, email and auth examples are the pattern rather than our code — adapt the thresholds to your traffic, because a silence that means disaster at your volume means nothing at someone else's. Last reviewed 23 August 2026.

Changelog, feedback and uptime in one place

Free for one project, with every module included. Five minutes to set up, and the public page is yours.

Health endpoints that tell you which part broke · FeedFast