A ping to your home page tells you the server answers. It does not tell you whether the database responds, whether the queue is filling up, or whether the payment provider has been throwing errors for ten minutes. That is what health endpoints are for: a URL where the service checks its own dependencies and reports on each one. The catch is that there is no single format. A Java team uses Spring Boot Actuator, a .NET team uses ASP.NET Core Health Checks, another follows the IETF health+json draft, and the platform runs on Kubernetes, which has a style of its own. In this article we show what each one looks like, where configuration usually goes wrong, and how AAA Sentinel reads all of them without asking you to change anything.
Four dialects for the same question
They all answer "are you OK?", just in different words:
- health+json: an IETF draft (draft-inadarei-api-health-check) with the application/health+json content type and three statuses: pass, warn and fail.
- Spring Boot Actuator: the /actuator/health endpoint, with the statuses UP, DOWN, OUT_OF_SERVICE and UNKNOWN.
- ASP.NET Core Health Checks: what MapHealthChecks publishes, with the statuses Healthy, Degraded and Unhealthy.
- Kubernetes: the API server's /livez and /readyz, which answer ok or, with ?verbose, one line per check.
health+json: the IETF draft
This is the most neutral format: it is not tied to any framework. The document carries an overall status and, optionally, a checks object. Each key in checks names a component and what is measured, in the form component:measurement, and its value is a list, because the same component can be measured on several nodes. Each entry has its own status and, if you want, observedValue, observedUnit, componentType, componentId, time and output.
HTTP/1.1 200 OK
Content-Type: application/health+json
Cache-Control: no-store
{
"status": "warn",
"version": "1.4.2",
"checks": {
"postgres:responseTime": [
{
"componentType": "datastore",
"observedValue": 12,
"observedUnit": "ms",
"status": "pass",
"time": "2026-10-18T09:00:00Z"
}
],
"orders-queue:depth": [
{
"componentType": "component",
"observedValue": 1480,
"status": "warn",
"time": "2026-10-18T09:00:00Z"
}
]
}
}- pass: healthy. warn: working, with some concern. fail: not working.
- The draft accepts synonyms: ok and up mean pass; error and down mean fail.
- The HTTP code goes along with the status: 2xx for pass and warn, 4xx or 5xx for fail (503 is the usual choice).
- Optional service-level fields: version, releaseId, serviceId, description, notes and output.
Spring Boot Actuator: /actuator/health
With spring-boot-starter-actuator on the classpath, /actuator/health is exposed over HTTP from the first start. What is missing is the detail: show-details defaults to never, so the response stops at the aggregate status.
GET /actuator/health
HTTP/1.1 200 OK
{ "status": "UP" }GET /actuator/health
HTTP/1.1 503 Service Unavailable
{
"status": "DOWN",
"components": {
"db": {
"status": "DOWN",
"details": { "error": "org.springframework.jdbc.CannotGetJdbcConnectionException" }
},
"diskSpace": {
"status": "UP",
"details": { "total": 53687091200, "free": 31138512896, "threshold": 10485760, "exists": true }
},
"ping": { "status": "UP" }
}
}Since Spring Boot 2.2 the indicators live under components; before that they were under details. By default DOWN and OUT_OF_SERVICE answer 503, while UP and UNKNOWN answer 200. You change that mapping with management.endpoint.health.status.http-mapping.
Showing the components in Spring
There are two separate properties, and it pays not to mix them up. show-components decides whether the list of components and their statuses appears; show-details decides whether each one's detail appears too (database version, disk space, error messages). If you leave show-components unset, it takes the value of show-details. An external monitor only needs the statuses, so a sensible setup looks like this:
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-components: always
show-details: when-authorized
probes:
enabled: true- show-components: always shows the status of db, diskSpace, redis and the rest to anyone, with no internal data.
- show-details: when-authorized keeps the detail for authenticated users (and, if you set management.endpoint.health.roles, for those roles).
- probes.enabled also exposes /actuator/health/liveness and /actuator/health/readiness; on Kubernetes they switch on by themselves.
- If you would rather expose nothing on the public port, management.server.port moves Actuator to a different one.
ASP.NET Core Health Checks
In .NET you register checks with AddHealthChecks and expose the endpoint with MapHealthChecks. Each check returns a HealthCheckResult, and the aggregate report takes the worst status.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>("database")
.AddCheck<QueueHealthCheck>("queue");
var app = builder.Build();
app.MapHealthChecks("/health");
app.Run();GET /health
HTTP/1.1 200 OK
Content-Type: text/plain
DegradedThe default writer returns a single word as plain text: Healthy, Degraded or Unhealthy. No components. And by default Degraded answers 200, just like Healthy; only Unhealthy answers 503. A monitor that looks only at the HTTP code will never see a degraded service. You change the codes with HealthCheckOptions.ResultStatusCodes.
A JSON writer for ASP.NET
To get the per-component status you need a ResponseWriter. The dependency-free option is to write your own, in the style of the sample in Microsoft's documentation, with a results property:
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = (context, report) => context.Response.WriteAsJsonAsync(new
{
status = report.Status.ToString(),
results = report.Entries.ToDictionary(
entry => entry.Key,
entry => new
{
status = entry.Value.Status.ToString(),
description = entry.Value.Description,
}),
}),
});{
"status": "Degraded",
"results": {
"database": { "status": "Healthy", "description": null },
"queue": { "status": "Degraded", "description": "1480 messages waiting" }
}
}- Serialise the status with ToString(). If you serialise the HealthStatus enum as it is, without JsonStringEnumConverter, it comes out as a number: 0 is Unhealthy, 1 is Degraded and 2 is Healthy.
- Keep exceptions and connection strings out of the response: health endpoints are often public.
The community option: HealthChecks.UI.Client
Many projects use AspNetCore.HealthChecks.UI.Client, from the community project AspNetCore.Diagnostics.HealthChecks (it is not a Microsoft package). Its writer returns the checks under an entries property, with durations and tags:
// dotnet add package AspNetCore.HealthChecks.UI.Client
using HealthChecks.UI.Client;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
app.MapHealthChecks("/health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse,
});{
"status": "Degraded",
"totalDuration": "00:00:00.0153210",
"entries": {
"database": { "data": {}, "duration": "00:00:00.0081120", "status": "Healthy", "tags": [] },
"queue": { "data": {}, "description": "1480 messages waiting", "duration": "00:00:00.0040310", "status": "Degraded", "tags": [] }
}
}Kubernetes: /livez and /readyz
The Kubernetes API server exposes /livez and /readyz (plus the older /healthz). Without parameters they answer ok with a 200. With ?verbose they return one line per check: [+]name ok when it passes and [-]name failed when it does not, with a 500 when any check fails. Some applications copy that format for their own endpoints.
GET /readyz?verbose
HTTP/1.1 500 Internal Server Error
[+]ping ok
[+]log ok
[-]etcd failed: reason withheld
[+]informer-sync ok
readyz check failedHow the statuses map
All four formats have a healthy status and a down status. The nuance is in the middle: warn, Degraded and UNKNOWN are not an outage. This is how AAA Sentinel reads them:
Health contract · from each status to healthy, degraded or down
- health+json
- pass, ok, up → healthy · warn → degraded · fail, error, down → down
- Spring Boot Actuator
- UP → healthy · UNKNOWN → degraded · DOWN, OUT_OF_SERVICE → down
- ASP.NET Core
- Healthy (or 2) → healthy · Degraded (or 1) → degraded · Unhealthy (or 0) → down
- Kubernetes
- ok or [+]name → healthy · [-]name → down
- HTTP code outside 2xx
- Down, whatever the body says
- Body that matches no contract
- The HTTP code decides, like a ping; if the body was cut short, degraded
The usual traps
Most false "all good" readings come from one of these:
- Actuator without show-components or show-details: all you get is {"status":"UP"}, and a failing component only shows up as an overall DOWN, with no clue which one.
- ASP.NET with the default writer: one plain-text word and a 200 for Degraded.
- The HTTP code and the body disagree: a 200 with status fail (custom writers that always answer 200) or a 503 with status pass (a proxy in front returning its own error).
- Degraded is not down: if a warn wakes someone up at 3 a.m., the team will end up muting the monitor.
- Case matters: up is health+json and UP is Actuator. A half-way Up is neither.
- A health endpoint that takes 30 seconds because it waits on a hung dependency. Give every check a timeout.
How the AAA Sentinel health contract reads them
The health contract monitor sends a GET to your HTTPS URL and reads the response as it comes. You do not pick the format when you create it: it recognises it from the body. These are the rules:
- If the body is JSON, it tries in this order: health+json (lowercase status, synonyms included), Actuator (uppercase status, components under components or details) and ASP.NET (Healthy, Degraded or Unhealthy, components under results or entries).
- A numeric status is only read as the .NET enum when it comes with its entries under results or entries; a bare number could belong to anything.
- If it is not JSON, it reads the plain text of the ASP.NET default writer and, failing that, the Kubernetes output: ok or one [+]/[-] line per check.
- It takes each component's status. In health+json, when a key holds several entries (several nodes), the worst one counts.
- The service status is the worst of what it declares and what its components say: a pass with a component in fail is down.
- If the HTTP code is not 2xx, it is down even if the body says pass. If it is 2xx and the body says fail, it is down too: exactly the case a ping misses.
- If the body matches no contract, the HTTP code decides. If it was cut short for size, it is not taken as healthy: it stays degraded.
- You can set a maximum latency (up to 10 seconds): a slower response counts as down.
Degraded and down are handled differently
A degraded service is still serving, so it is not treated as an outage:
- Degraded (a warn, a Degraded or an UNKNOWN): it is recorded and the status page shows it as degraded, but it does not open an incident or fire like an outage.
- Down: it goes through the usual state machine. It first becomes suspect, the check is repeated from a second AWS region (eu-central-1) and, only if confirmed, it opens an incident and alerts by email, Slack, Microsoft Teams or webhook.
- When it is healthy again, the incident closes by itself and you get the recovery notice.
- During the first 24 hours the monitor observes and proposes thresholds without alerting.
If your endpoint needs credentials, the monitor supports bearer, basic, API key, AWS SigV4 and Azure AD or Google Cloud tokens using the identity of the account you already connected. Every probe carries an X-AAA-Signature header, signed with a secret of your organisation, so you can tell it apart from the rest of your traffic.
health+json in Node with @aaa-sentinel/health
If your service runs on Node and has no health endpoint yet, @aaa-sentinel/health (on npm, no dependencies, Node 20 or later) gives you one in health+json. You pass an object with one function per component and it returns the complete HTTP response. In a Lambda behind API Gateway or a function URL, you return it as it is:
import { runHealthChecks } from '@aaa-sentinel/health'
export const handler = () =>
runHealthChecks(
{
database: async () => {
await db.query('select 1')
},
queue: async () => ((await queueDepth()) < 1000 ? 'pass' : 'warn'),
payments: async (signal) => {
const started = Date.now()
await fetch('https://payments.internal/ping', { signal })
return { observedValue: Date.now() - started, observedUnit: 'ms' }
},
},
{ version: process.env.APP_VERSION, timeoutMs: 2000 },
)import express from 'express'
import { runHealthChecks } from '@aaa-sentinel/health'
const app = express()
app.get('/health', async (_req, res) => {
const r = await runHealthChecks(checks, { version: process.env.APP_VERSION })
res.writeHead(r.statusCode, r.headers).end(r.body)
})HTTP/1.1 200 OK
Content-Type: application/health+json
Cache-Control: no-store
{
"status": "warn",
"version": "1.4.2",
"checks": {
"database": [{ "status": "pass", "time": "2026-10-18T09:00:00.120Z" }],
"queue": [{ "status": "warn", "time": "2026-10-18T09:00:00.118Z" }],
"payments": [{ "observedValue": 41, "observedUnit": "ms", "status": "pass", "time": "2026-10-18T09:00:00.161Z" }]
}
}- A check that returns nothing or true is a pass; false is a fail; it can also return pass, warn or fail, or an object with observedValue and observedUnit.
- Checks run at the same time, each with its own timeout (timeoutMs, 2 seconds by default and 60 at most). One that throws or runs out of time is a fail for that component, and the endpoint still answers. Each check gets an AbortSignal to cancel its call.
- The overall status is the worst check. fail answers 503; pass and warn, 200. Load balancers understand that as well.
- Error messages stay out of the response unless you pass exposeErrors: true, because they often contain hosts or connection strings.
- minimumStatus: 'warn' keeps the service at warn or worse, during maintenance for example.
- If you already have the results, healthResponse builds the same response without running anything, and healthBody gives you just the document.
Checklist
Before you create the monitor, make sure your endpoint does this:
- Answers a GET over HTTPS, without redirecting to a login page.
- Returns each component's status: show-components in Spring, a JSON ResponseWriter in ASP.NET, checks in health+json.
- Does not expose internal details to people who should not see them.
- Every check has a timeout and the endpoint answers within a few seconds.
- Tells degraded from down: warn, Degraded or UNKNOWN for whatever half works.
- The HTTP code agrees with the body: 503 when the status is down.
- On Node, @aaa-sentinel/health gives you the format, the timeouts and the HTTP code in a few lines.