A dependency-free core runs probes against your database, cache and
upstream APIs and renders ok | degraded | unhealthy. Thin adapters mount it
as GET /health on Hono, Elysia, Express, Next.js, TanStack Start or any
Fetch-API server. Thin probe packages know how to ping one dependency each.
Runs on Deno, Node ≥ 22, Bun and edge runtimes. Published to JSR and npm.
These are the /health endpoints behind openstatus's
own services, extracted so any server can expose one.
Quick start
Install the core, one adapter and the probes you need:
npm install @openstatus/health @openstatus/health-hono @openstatus/health-turso
# or
deno add jsr:@openstatus/health jsr:@openstatus/health-hono jsr:@openstatus/health-tursoMount the route:
import { Hono } from "hono"; import { createClient } from "@libsql/client"; import { healthRoute } from "@openstatus/health-hono"; import { tursoProbe } from "@openstatus/health-turso"; import { unkeyProbe } from "@openstatus/health-unkey"; const client = createClient({ url: process.env.TURSO_URL!, authToken: process.env.TURSO_TOKEN }); const app = new Hono(); app.route("/", healthRoute({ probes: [tursoProbe({ client }), unkeyProbe()] })); export default app;
GET /health answers with the aggregate status and one entry per probe:
{
"status": "degraded",
"checkedAt": "2026-09-11T12:00:00.000Z",
"latencyMs": 41,
"checks": [
{ "name": "database", "status": "ok", "critical": true, "latencyMs": 3 },
{ "name": "redis", "status": "skipped", "critical": false, "latencyMs": 0 },
{ "name": "unkey", "status": "timeout", "critical": false, "latencyMs": 5000, "error": "timed out after 5000ms" }
]
}ok and degraded answer 200, unhealthy answers 503; both codes are
configurable. A failing or timed-out critical probe makes the report
unhealthy, a failing non-critical probe makes it degraded, and skipped
probes never affect it. Responses are sent with Cache-Control: no-store.
Why @openstatus/health
- Zero dependencies in the core. The probe runner, cache and renderer have no runtime dependencies, so the endpoint you add is the endpoint you audit.
- One package per concern, tree-shakable by construction. Import
@openstatus/health-honowithout pulling Express; import@openstatus/health-unkeywithout pulling Drizzle. CI bundles a one-line consumer of every package and fails if an unrelated library lands in the output. - Same behaviour on every framework. Adapters contain no response logic; they mount one core, so status codes, caching and rendering are identical whether you run Hono on Bun or Express on Node.
- Probes never read your environment. A probe takes a client or a URL, so
it is testable and shareable; optional dependencies report
skippedinstead of failing when they are not configured. - Built for real infrastructure. Per-probe timeouts, a whole-round
deadlineMs, caching, stale-while-revalidate, and separate liveness and readiness routes. - Know which replica answered. Hosting packages add
region,instanceId,serviceandversionunderserverthrough theextendhook.
Every adapter exports healthRoute(options), the batteries-included form
that mounts GET and HEAD on options.path (default /health). All but
Next.js also export healthHandler(options) — a plain handler for that
framework — for when you want to pick the path, stack your own middleware in
front, or register it the way you register everything else.
Hono
import { Hono } from "hono"; import { healthHandler, healthRoute } from "@openstatus/health-hono"; import { tursoProbe } from "@openstatus/health-turso"; import { unkeyProbe } from "@openstatus/health-unkey"; const app = new Hono(); app.route("/", healthRoute({ probes: [tursoProbe({ client }), unkeyProbe()], extend: (_report, c) => ({ requestId: c.get("requestId") }), })); // or, on a route of your own: app.on(["GET", "HEAD"], "/health", healthHandler({ probes: [unkeyProbe()] }));
Elysia
import { Elysia } from "elysia"; import { healthRoute } from "@openstatus/health-elysia"; import { tinybirdProbe } from "@openstatus/health-tinybird"; new Elysia().use(healthRoute({ probes: [tinybirdProbe()] })).listen(3000);
Express
import express from "express"; import { healthRoute } from "@openstatus/health-express"; import { drizzleProbe } from "@openstatus/health-drizzle"; const app = express(); app.use(healthRoute({ probes: [drizzleProbe({ db })] }));
Next.js (App Router)
// app/health/route.ts import { healthRoute } from "@openstatus/health-next"; import { supabaseProbe } from "@openstatus/health-supabase"; // required: keeps Next.js from statically caching the route export const dynamic = "force-dynamic"; export const { GET, HEAD } = healthRoute({ probes: [supabaseProbe({ client })] });
TanStack Start
// src/routes/api/health.ts import { createFileRoute } from "@tanstack/react-router"; import { healthRoute } from "@openstatus/health-tanstack-start"; import { supabaseProbe } from "@openstatus/health-supabase"; export const Route = createFileRoute("/api/health")({ server: { handlers: healthRoute({ probes: [supabaseProbe({ client })] }) }, });
Anything with a Fetch API (Deno.serve, Bun.serve, Workers)
import { createHealthHandler } from "@openstatus/health"; Deno.serve(createHealthHandler({ path: "/health", probes: [/* ... */] }));
Runnable projects for each adapter live in examples/.
Packages
Core
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health |
Probe runner, caching, response rendering, Fetch-API handler |
Server adapters
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-hono |
Hono adapter | ||
@openstatus/health-elysia |
Elysia adapter | ||
@openstatus/health-express |
Express 4 / 5 adapter | ||
@openstatus/health-next |
Next.js App Router adapter | ||
@openstatus/health-tanstack-start |
TanStack Start adapter |
Probes
Databases
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-clickhouse |
ClickHouse ping / SELECT 1 probe (@clickhouse/client) |
||
@openstatus/health-cloudflare-d1 |
Cloudflare D1 select 1 probe over the Workers binding |
||
@openstatus/health-convex |
Convex query probe over the deployment HTTP API | ||
@openstatus/health-drizzle |
Drizzle ORM select 1 probe |
||
@openstatus/health-mongodb |
MongoDB ping command probe (mongodb) |
||
@openstatus/health-mysql |
MySQL / MariaDB select 1 probe (mysql2/promise) |
||
@openstatus/health-neon |
Neon serverless Postgres select 1 probe (@neondatabase/serverless) |
||
@openstatus/health-planetscale |
PlanetScale select 1 probe over the serverless driver (@planetscale/database) |
||
@openstatus/health-postgres |
Postgres select 1 probe (pg, postgres.js, Neon, Vercel Postgres) |
||
@openstatus/health-prisma |
Prisma select 1 / ping probe (@prisma/client) |
||
@openstatus/health-supabase |
Supabase connection-pressure probe | ||
@openstatus/health-tinybird |
Tinybird reachability probe | ||
@openstatus/health-turso |
Turso libSQL select 1 probe (@libsql/client) |
||
@openstatus/health-turso-serverless |
Turso select 1 probe over the serverless driver (@tursodatabase/serverless) |
Caches & KV
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-cloudflare-kv |
Cloudflare Workers KV read probe over the Workers binding | ||
@openstatus/health-redis |
Redis / Valkey PING probe (node-redis, ioredis, @upstash/redis) |
||
@openstatus/health-upstash |
Upstash Redis PING probe over REST |
Object storage
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-cloudflare-r2 |
Cloudflare R2 HEAD probe over the Workers binding |
||
@openstatus/health-s3 |
S3 HeadBucket probe (@aws-sdk/client-s3; AWS, R2, Tigris, MinIO) |
Queues & workflows
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-bullmq |
BullMQ waiting-count / backlog probe (bullmq) |
||
@openstatus/health-inngest |
Inngest REST API reachability probe | ||
@openstatus/health-qstash |
Upstash QStash reachability probe over REST | ||
@openstatus/health-trigger-dev |
Trigger.dev API reachability probe |
Messaging
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-kafka |
Kafka describeCluster() probe (kafkajs) |
||
@openstatus/health-nats |
NATS flush() round-trip probe (@nats-io/*, nats) |
Search
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-algolia |
Algolia /1/isalive reachability probe |
||
@openstatus/health-meilisearch |
Meilisearch /health probe (cloud or self-hosted) |
||
@openstatus/health-typesense |
Typesense /health probe (cloud or self-hosted) |
SaaS APIs
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-anthropic |
Anthropic API reachability probe | ||
@openstatus/health-clerk |
Clerk Backend API reachability probe | ||
@openstatus/health-openai |
OpenAI API reachability probe (or any OpenAI-compatible host) | ||
@openstatus/health-posthog |
PostHog API reachability probe (cloud or self-hosted) | ||
@openstatus/health-resend |
Resend API reachability probe | ||
@openstatus/health-sentry |
Sentry API reachability probe (SaaS or self-hosted) | ||
@openstatus/health-stripe |
Stripe API reachability probe | ||
@openstatus/health-unkey |
Unkey liveness probe | ||
@openstatus/health-workos |
WorkOS API reachability probe |
Network & protocols
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-dns |
DNS lookup probe for a hostname (node:dns) |
||
@openstatus/health-grpc |
gRPC Health/Check probe through a @grpc/grpc-js health client |
||
@openstatus/health-tcp |
TCP connect probe for any host:port (node:net) |
||
@openstatus/health-tls |
TLS handshake, trust and certificate-expiry probe (node:tls) |
System
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-disk |
Free disk space threshold probe (node:fs statfs) |
||
@openstatus/health-memory |
Heap / RSS memory pressure probe (node:process, node:v8) |
Hosting metadata
| Package | JSR | npm | Description |
|---|---|---|---|
@openstatus/health-fly |
Fly.io region, machine and deployment | ||
@openstatus/health-koyeb |
Koyeb region, instance and deployment | ||
@openstatus/health-railway |
Railway region, replica, environment and deployment | ||
@openstatus/health-vercel |
Vercel region, environment and deployment | ||
@openstatus/health-cloudflare |
Cloudflare Workers colo and version metadata |
Missing a framework or a dependency? Adapters and probes are small —
open an issue or send a
PR; AGENTS.md walks through adding a package.
Probes
Databases
| Probe | Default name | Critical | Checks |
|---|---|---|---|
clickhouseProbe({ client, select? }) |
clickhouse |
no | client.ping({ select: true }) on a @clickhouse/client client |
convexProbe({ url, path, args?, token? }) |
database |
yes | POST {url}/api/query running path answers status: "success" |
d1Probe({ db }) |
database |
yes | db.prepare("select 1").first() on a Workers D1Database binding |
drizzleProbe({ db }) |
database |
yes | db.execute(sql\select 1`)ordb.run(...)` |
mongodbProbe({ client, db? }) |
database |
yes | client.db("admin").command({ ping: 1 }) on a MongoClient |
mysqlProbe({ client }) |
database |
yes | client.query("select 1") on a mysql2/promise pool or connection |
neonProbe({ client }) |
database |
yes | client.query("select 1") on neon(), or a Neon Pool / Client |
planetscaleProbe({ connection }) |
database |
yes | connection.execute("select 1") on a @planetscale/database connection |
postgresProbe({ client }) |
database |
yes | client.query("select 1") on a pg pool, or sql.unsafe("select 1") on postgres.js |
prismaProbe({ client }) |
database |
yes | client.$queryRawUnsafe("select 1"), or client.$runCommandRaw({ ping: 1 }) on MongoDB |
supabaseProbe({ client, maxConnectionPercent? }) |
supabase |
no | rpc("health_connection_pressure") ≤ threshold |
tinybirdProbe({ baseUrl? }) |
tinybird |
no | GET {baseUrl}/v0/health |
tursoProbe({ client }) |
database |
yes | client.execute("select 1") on a Turso libSQL client |
tursoServerlessProbe({ connection }) |
database |
yes | connection.get("select 1") on a Turso serverless Connection |
Caches & KV
| Probe | Default name | Critical | Checks |
|---|---|---|---|
kvProbe({ namespace, key? }) |
kv |
no | namespace.get("health") on a Workers KVNamespace binding |
redisProbe({ client }) |
redis |
no | client.ping() answers PONG on a node-redis, ioredis or Upstash client |
upstashProbe({ url, token }) |
redis |
no | GET {url}/ping with the REST token |
Object storage
| Probe | Default name | Critical | Checks |
|---|---|---|---|
r2Probe({ bucket, key? }) |
storage |
no | bucket.head(key ?? "health") on a Workers R2Bucket binding |
s3Probe({ client, bucket }) |
storage |
no | client.send(new HeadBucketCommand({ Bucket })) on an S3Client |
Queues & workflows
| Probe | Default name | Critical | Checks |
|---|---|---|---|
bullmqProbe({ queue, maxWaiting? }) |
queue |
no | queue.getWaitingCount() on a BullMQ Queue, ≤ threshold |
inngestProbe({ signingKey, baseUrl? }) |
inngest |
no | GET {baseUrl}/v1/events?limit=1 with the signing key |
qstashProbe({ token, baseUrl? }) |
qstash |
no | GET {baseUrl}/v2/queues with the token |
triggerDevProbe({ secretKey, baseUrl? }) |
trigger |
no | GET {baseUrl}/api/v1/runs?page[size]=1 with the secret key |
Messaging
| Probe | Default name | Critical | Checks |
|---|---|---|---|
kafkaProbe({ admin }) |
kafka |
no | admin.describeCluster() lists ≥ 1 broker on a connected KafkaJS Admin |
natsProbe({ connection }) |
nats |
no | connection.flush() on an open NatsConnection |
Search
| Probe | Default name | Critical | Checks |
|---|---|---|---|
algoliaProbe({ appId, apiKey, baseUrl? }) |
search |
no | GET {baseUrl}/1/isalive with the app headers; baseUrl defaults to https://{appId}-dsn.algolia.net |
meilisearchProbe({ host, apiKey? }) |
search |
no | GET {host}/health answers status: "available" |
typesenseProbe({ host, apiKey? }) |
search |
no | GET {host}/health answers ok: true |
SaaS APIs
| Probe | Default name | Critical | Checks |
|---|---|---|---|
anthropicProbe({ apiKey, baseUrl? }) |
anthropic |
no | GET {baseUrl}/v1/models with the x-api-key header |
clerkProbe({ secretKey, baseUrl? }) |
clerk |
no | GET {baseUrl}/v1/users?limit=1 with the secret key |
openaiProbe({ apiKey, baseUrl? }) |
openai |
no | GET {baseUrl}/v1/models with the API key |
posthogProbe({ personalApiKey, baseUrl? }) |
posthog |
no | GET {baseUrl}/api/projects/@current/ with the personal API key |
resendProbe({ apiKey, baseUrl? }) |
resend |
no | GET {baseUrl}/domains with the API key |
sentryProbe({ token?, baseUrl? }) |
sentry |
no | GET {baseUrl}/api/0/, with the auth token when given |
stripeProbe({ secretKey, baseUrl? }) |
stripe |
no | GET {baseUrl}/v1/balance with the secret key |
unkeyProbe({ baseUrl? }) |
unkey |
no | GET {baseUrl}/v2/liveness |
workosProbe({ apiKey, baseUrl? }) |
workos |
no | GET {baseUrl}/organizations?limit=1 with the API key |
Network & protocols
| Probe | Default name | Critical | Checks |
|---|---|---|---|
dnsProbe({ hostname, lookup? }) |
dns |
no | dns.promises.lookup(hostname) returns an address |
grpcProbe({ client, service? }) |
grpc |
no | client.check({ service }) answers SERVING on a grpc.health.v1.Health client |
tcpProbe({ host, port }) |
tcp |
no | a TCP connection to host:port is accepted |
tlsProbe({ host, port?, minDaysValid? }) |
tls |
no | a TLS handshake with a trusted certificate valid ≥ minDaysValid days |
System
| Probe | Default name | Critical | Checks |
|---|---|---|---|
diskProbe({ path?, minFreePercent?, minFreeBytes? }) |
disk |
no | free space of the filesystem holding path ≥ threshold |
memoryProbe({ maxHeapUsedPercent?, maxRssBytes? }) |
memory |
no | heap in use ≤ 90% of the V8 heap limit by default; RSS ≤ budget only when maxRssBytes is set |
Every probe factory accepts name, critical, timeoutMs and skip
overrides. Probes take a client instance, a base URL or, for tcpProbe, a
host and port — they never read process.env themselves.
Writing your own probe
A probe is a plain object. Resolve for healthy, reject or throw for failed,
and honour the AbortSignal so a timeout actually cancels the work:
import { httpProbe, probe } from "@openstatus/health"; const queue = probe({ name: "queue", critical: true, timeoutMs: 1000, skip: () => !env.QUEUE_URL, run: async (signal, ctx) => { const res = await fetch(`${env.QUEUE_URL}/depth`, { signal }); if (!res.ok) throw new Error(`${ctx.name} answered ${res.status}`); const { depth } = await res.json(); if (depth > 10_000) throw new Error(`queue depth ${depth}`); }, }); const docs = httpProbe({ name: "docs", url: "https://docs.example.com", method: "HEAD" });
skip runs on every request, may be async, and reports the check as
skipped without running it — use it for optional dependencies that are not
configured in every environment. ctx carries the probe's name, critical
flag and effective timeoutMs.
@openstatus/health/testing exports fakeFetch, hangFetch and ready-made
okProbe / failingProbe / hangingProbe fixtures for testing probes and
adapters of your own.
Options
Every entry point takes the same options — probes (or a shared check),
path, cacheMs, cacheFailuresMs, staleMs, timeoutMs, deadlineMs,
exposeChecks, unhealthyStatusCode, degradedStatusCode, extend,
formatError, onReport, onError — documented once in
packages/health.
Errors are masked as "failed" unless you opt in with
formatError: "message".
Liveness, readiness, public and internal
Liveness is "the process answers"; readiness is "the process can serve".
Mount the same adapter twice — an empty probe list is always ok:
app.route("/", healthRoute({ path: "/livez", probes: [] })); app.route("/", healthRoute({ path: "/readyz", probes, deadlineMs: 800, cacheFailuresMs: 0 }));
deadlineMs caps the whole round so a hung dependency cannot outlast a
Kubernetes probe's timeoutSeconds; cacheFailuresMs: 0 lets the next poll
see a recovery immediately. Set staleMs to keep answering from the last
report while a refresh runs in the background.
One /health can serve the load balancer and your on-call engineer:
exposeChecks takes a function of the request, and extend output is only
rendered when checks are exposed. If you would rather serve two routes, build
the check once and share it so the probes run once per cache window:
import { createHealthCheck } from "@openstatus/health"; const check = createHealthCheck({ probes, cacheMs: 5000, onReport: log }); app.route("/", healthRoute({ check, exposeChecks: false })); app.route("/", healthRoute({ check, path: "/_health", extend: flyExtend() })); // or, one route: app.route("/", healthRoute({ check, exposeChecks: (c) => c.req.header("x-health-token") === env.HEALTH_TOKEN, extend: flyExtend(), }));
check.invalidate() drops the cache — call it after a reconnect or a config
reload.
Server metadata
The hosting packages answer a different question from the probes: not "is the
database up" but "which replica is telling me that". Each reads its platform's
own environment — or, on Workers, the request — and renders it under server
through the same extend hook:
import { healthRoute } from "@openstatus/health-hono"; import { flyExtend } from "@openstatus/health-fly"; app.route("/", healthRoute({ probes, extend: flyExtend() }));
{
"status": "ok",
"checkedAt": "2026-09-11T12:00:00.000Z",
"latencyMs": 41,
"checks": [{ "name": "database", "status": "ok", "critical": true, "latencyMs": 3 }],
"server": {
"platform": "fly",
"region": "ams",
"instanceId": "148e21ebd47089",
"service": "openstatus-api",
"version": "registry.fly.io/openstatus-api:deployment-01H9RK9EYO9PGNBYAKGXSHV0PH",
"primaryRegion": "cdg"
}
}platform, region, instanceId, service, version and environment mean
the same thing on every platform; anything else is named as that platform names
it. A field is absent rather than guessed when the platform has no equivalent —
Vercel exposes no instance identity, so there is no instanceId there. Values
are passed through exactly as the platform sets them, so region is ams on
Fly and DFW on Cloudflare.
Each package also exports the data on its own — flyServer(), vercelServer()
— so you can compose it with your own fields, or chain platforms if one build
deploys to several. extend may return anything JSON.stringify accepts;
the report's own fields always take precedence over keys of the same name:
extend: (_report, c) => ({ server: flyServer() ?? vercelServer(), requestId: c.get("requestId"), }),
Off-platform they return undefined and nothing is rendered, so the same build
runs unchanged on your laptop. extend follows exposeChecks: when the checks
are hidden, so is everything extend adds.
Monitoring the endpoint
A /health that reports degraded is only useful if something reads it.
Point an openstatus HTTP monitor
at the endpoint and assert on status in the body to be alerted on
degraded before it becomes unhealthy. Any poller that checks the HTTP
status code — Kubernetes, Fly, Railway, a load balancer — gets 503 on
unhealthy without further configuration.
Development
deno task check # type-check, lint, fmt, version consistency deno task test # node:test suites under Deno deno task build # tsdown -> dist/ for every package deno task test:node # the same suites under Node against dist/ deno task check:treeshake # no package bundles another framework/client deno task test-all # all of the above
See AGENTS.md for layout and conventions,
CHANGES.md for the changelog and
RELEASING.md for the release checklist.
Contributing
Issues and PRs are welcome — a bug, a new adapter or probe, or a docs fix.
- Read
AGENTS.mdfor the layout and conventions. - Run
deno task test-allbefore opening a PR. - Join the Discord to ask questions.
About openstatus
openstatus is an open-source uptime monitoring
and status page platform. It monitors endpoints from regions around the world
and turns the results into status pages and alerts. These packages are the
/health endpoints behind openstatus's own services, extracted so any
JavaScript server can expose one — and so a monitor has something more useful
to poll than 200 OK.