How-to › Expose your API to agents

How to add a health endpoint agents can poll#

Return a status, a release id, and one line per dependency, cache it for seconds, and prove the endpoint goes red when a dependency goes down.

Audience
Platform team
Level
beginner
Topic
Make a docs site agent-ready
Languages
TypeScript
Verified

An agent calls your API, gets a timeout, and has to decide whether to retry, wait, or give up. It polls /health, which answers 200 because the process is running. The database behind it has been down for ten minutes. Nothing the agent can read says why every attempt fails, so it retries into the outage.

What you get

You will end up with a health endpoint that reports a status, a release id, and one line per dependency. A test takes a dependency down and watches it go red. This is for you if agents poll your API before calling it.

Short answer

Serve GET /health with a body in the application/health+json shape: an overall status of pass, warn, or fail, a version, a releaseId, and a checks object with one entry per dependency. Run every check on every request under a deadline, return 503 when any check fails, and send Cache-Control: max-age=5 so a crowd of polling clients costs one check per five seconds.

You will need

Node 22 or later, and a deployed API or docs site to put the route on. Verified 2026-09-24 against Node 22.22.2. The body shape follows the IETF health check draft, an Internet-Draft that expired without becoming an RFC,1 so its field names are a convention with a written description rather than a standard. That is still better than a convention with none.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A hosted status page such as Atlassian StatuspagePeople need history, incident text, and subscriptionsSomebody has to post the update, so the page lags the outage by however long that takesThe reader is a program deciding whether to retry in the next second
A plain JSON health routeA restart probe, where the only question is whether the process is aliveSays nothing about dependencies, so it stays green while the database is downAnything that decides whether to call the API rather than whether to restart it
application/health+json from the IETF draftAgents and monitors that want a per-dependency answer in one requestA draft rather than a standard, and every check runs on every poll unless you cacheThe API has no dependencies worth reporting, or the clients read only the status code

The plain route and the status page fail in opposite directions. The route is automatic and says nothing, so it stays green through an outage the database has been having for an hour. The status page says a great deal and is typed by a person, so it turns red after somebody notices. The health+json body sits between them: automatic, and specific about which dependency failed.

A status page still earns its place beside the endpoint. An agent reads the endpoint to decide what to do in the next second. A person reads the status page to find out what happened last Tuesday, and the endpoint has no memory of that.

Run every check under a deadline

A health check that calls the database inherits the database’s failure modes, including the one where it never answers. The endpoint has to answer anyway.

async function runCheck(check, timeoutMs) {
  let timer
  const deadline = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs)
  })
  const started = new Date().toISOString()
  try {
    const { status = 'pass', ...result } = (await Promise.race([check.run(), deadline])) ?? {}
    return { componentType: check.componentType, status: canonical(status), time: started, ...result }
  } catch (err) {
    return { componentType: check.componentType, status: 'fail', time: started, output: err.message }
  } finally {
    clearTimeout(timer)
  }
}

A check that throws becomes a fail entry with the error text in output. A check that hangs loses the race and becomes a fail entry that says so. Neither reaches the request handler as an exception, which is the property that keeps the endpoint up when a dependency is down. The draft asks for output to be omitted on pass, and the spread on the success path does that by never setting it. The status a check answers with goes through canonical, a seven-entry table: the draft accepts ok and up for pass and error and down for fail, and a status the table does not know ranks as fail. Without that, a check answering down reads as a pass, and the endpoint stays green.

Say which dependency failed

The overall status is the worst status of any check, and the checks are keyed by dependency and measurement, which the draft writes as component:measurement.

export async function healthReport({ version, releaseId, checks, timeoutMs = 2000 }) {
  const results = await Promise.all(checks.map((c) => runCheck(c, timeoutMs)))
  const body = { status: 'pass', version, releaseId, checks: {} }
  for (const [i, check] of checks.entries()) {
    body.checks[check.name] = [results[i]]
    if (RANK[results[i].status] > RANK[body.status]) body.status = results[i].status
  }
  return body
}

The checks run concurrently. Four dependencies with a two second deadline each answer in two seconds at worst, not eight.

version and releaseId are two fields because they move at different speeds. The draft separates the public API version, which changes rarely, from the release that is running, which changes on every deploy. An agent that sees a 500 at releaseId 2026.09.24-4f1c9e2 and a 200 at the next one has something to put in its bug report.

Each key holds an array, even for one node, so a dependency with three replicas can report three entries under one key without changing the shape. The componentType values here, datastore and component, are the ones the draft lists.

Cache for seconds, not minutes

Twenty agents polling every second is twenty database pings a second. A max-age of five seconds turns that into one, and delays the news of an outage by at most five seconds.

export function healthHandler(options, { maxAgeSeconds = 5 } = {}) {
  return async (req, res) => {
    const body = await healthReport(options)
    const status = httpStatus(body.status)
    const headers = {
      'content-type': 'application/health+json',
      'cache-control': `max-age=${maxAgeSeconds}`,
    }
    if (status === 503) headers['retry-after'] = String(maxAgeSeconds)
    res.writeHead(status, headers)
    res.end(JSON.stringify(body))
  }
}

The HTTP status carries the verdict for clients that never parse the body. The draft requires 2xx for pass and warn, and 4xx or 5xx for fail. RFC 9110 defines 503 as a server that cannot handle the request for the moment, with Retry-After as the hint for when to try again. Sending the same number in Retry-After and max-age means a client that honors either header comes back at the same time.

max-age is defined in RFC 9111, and a 503 is only cached because the header says so explicitly. The draft’s own example uses max-age=3600,2 which is an hour of not noticing. Keep it to seconds.

Check it worked

Start the server, poll it, take the database down, and poll it again. Then make the queue check hang.

node demo.mjs
everything up
  HTTP 200  application/health+json  cache-control: max-age=5
  status pass  version 1  releaseId 2026.09.24-4f1c9e2
  database:connections   pass  3 connections
  queue:depth            pass  12 messages
database down
  HTTP 503  application/health+json  cache-control: max-age=5
  status fail  version 1  releaseId 2026.09.24-4f1c9e2
  database:connections   fail  connect ECONNREFUSED 10.0.0.12:5432
  queue:depth            pass  12 messages
queue check hangs
  HTTP 503  application/health+json  cache-control: max-age=5
  status fail  version 1  releaseId 2026.09.24-4f1c9e2
  database:connections   pass  3 connections
  queue:depth            fail  timed out after 300ms

The second block is the one that matters. The status is 503, the body says fail, and the line for the database carries the error the connection attempt raised. The queue line still says pass, so a reader can see that one dependency is down and not the whole service.

The third block is the deadline doing its job. The queue check never returns, the endpoint answers in 300 milliseconds anyway, and the output names the timeout.

The tests pin the same three behaviors, plus a check that throws, a warn that keeps its 200, a check that answers with one of the draft’s aliases, and the cache header.

node --test health.test.mjs
1..7
# tests 7
# suites 0
# pass 7
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 355.551534

When it goes wrong

The endpoint is green and the API is down. The route returns 200 whenever the process can run a handler, without asking a dependency anything. Add the checks. Here is the difference with the database switched off, on one server with both routes.

node pitfall.mjs
/healthz  HTTP 200  says ok
/health   HTTP 503  says fail
database: down

The /healthz route is not wrong for the question a restart probe asks, which is whether the process is alive. It is wrong for an agent deciding whether to retry, and a route that answers both questions with the same 200 teaches agents to keep retrying against a database that is down.

The endpoint times out. One check has no deadline of its own and the dependency it calls has stopped answering. Every check goes through runCheck, which imposes one, so a check with no deadline is a check that bypassed the function.

The endpoint stays red after the outage ends. The cache is too long, or a proxy in front of the service added its own. Check the cache-control the client received, not the one you sent.

The polling clients take the service down. A health check that runs an expensive query is a query every agent can trigger for free. Keep each check to a ping or a cheap read, and let the cache absorb the rest.

When not to do this

Do not report fail for a dependency the request path can live without. A search index that is rebuilding takes down nothing an agent needs to create a record, and an endpoint that says fail for it sends every agent away. Report it as warn, which keeps the 200, and put the detail in the checks.

Do not put secrets or internal host names in output. The error text from a database driver often includes the host and port, as the demo shows, and the endpoint is public by design. Strip the message before it goes out, or keep the detailed body behind authentication and serve an unauthenticated one with the status alone.

Do not replace the status page with this. A person who wants to know what happened yesterday gets nothing from an endpoint that reports the last five seconds, and a status page with incident text is the record. Run both, and link the page from the endpoint’s links field.

Do not run the checks on a timer and serve the cached result if the cache is your only defense against polling clients. The max-age header already does that job in every HTTP client and proxy on the path, and a second cache inside the process is a second thing that can go stale.

Last verified

Verified 2026-09-24 against Node 22.22.2. Every output block is what the command preceding it printed. The dependencies are stubs in server.mjs with a switch the demo flips, not a database and a queue.

Footnotes

  1. The draft is draft-inadarei-api-health-check-06, which the IETF Datatracker files as expired, its sixth revision having lapsed in April 2022. It accepts ok and up as aliases for pass, and error and down for fail, and says in so many words that the aliases exist to accommodate Node’s Terminus and Java’s Spring Boot. A specification that names the two libraries it is bending to is rarer than one that bends without saying. ↩︎ Back to text

  2. The example response in section 5 of the draft carries Cache-Control: max-age=3600, so the document that defines a health check illustrates it with one that may be an hour stale. Nothing in the text explains the number. The example is the part people copy. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.