An endpoint can return 200 OK and still be broken: an empty list, an error message inside a JSON body, a field that changed type, or a response that takes eight seconds. This guide covers what to check, how to script those checks yourself with tested curl and Node.js examples, and what the free tiers of common hosted monitors include.
What to check
| Check | Catches |
|---|---|
| Status code is 2xx | Crashes, failed deploys, auth and routing errors |
| Content type is JSON | A proxy, CDN or login page answering with HTML instead of your API |
| A known value in the body | "Successful" responses that carry an error or empty data |
| Response time under a limit | Slow queries and overloaded servers before they time out |
| TLS certificate expiry | Renewals that failed silently |
| Heartbeat from scheduled jobs | Cron jobs and workers that stopped running. These do not serve requests, so they have to report in instead of being probed. |
Pick a lightweight endpoint that exercises the real dependencies, such as a /health route that queries the database, or a cheap read-only call. Use a dedicated, read-only credential for it, never a personal or admin token.
A check script with curl and jq
This script fails (exit status 1) when the status is not 2xx, the body does not match a jq filter, or the response is slower than a limit. It was tested with curl 8.7.1, jq 1.7.1 and bash 3.2 on macOS.
#!/usr/bin/env bash
# Usage: ./check-api.sh URL JQ_FILTER [MAX_SECONDS]
set -euo pipefail
url=$1
filter=$2
max=${3:-2}
body=$(mktemp)
trap 'rm -f "$body"' EXIT
read -r code time < <(curl -sS -o "$body" -w '%{http_code} %{time_total}\n' \
--max-time 10 "$url")
if [[ $code != 2* ]]; then
echo "FAIL $url: HTTP $code" >&2; exit 1
fi
if ! jq -e "$filter" "$body" > /dev/null; then
echo "FAIL $url: body does not match $filter" >&2; exit 1
fi
if awk -v t="$time" -v m="$max" 'BEGIN { exit !(t > m) }'; then
echo "FAIL $url: slow response (${time}s > ${max}s)" >&2; exit 1
fi
echo "OK $url: HTTP $code in ${time}s"Against the public test service httpbin.org it printed (timings vary):
$ ./check-api.sh https://httpbin.org/json '.slideshow.title == "Sample Slide Show"'
OK https://httpbin.org/json: HTTP 200 in 1.152885s
$ ./check-api.sh https://httpbin.org/status/500 '.'
FAIL https://httpbin.org/status/500: HTTP 500
$ ./check-api.sh https://httpbin.org/delay/3 '.url' 2
FAIL https://httpbin.org/delay/3: slow response (4.047483s > 2s)
$ ./check-api.sh https://nonexistent.invalid/ '.'
curl: (6) Could not resolve host: nonexistent.invalid
FAIL https://nonexistent.invalid/: HTTP 000jq -esets its exit status from the result, so a filter that evaluates tofalseornullfails the check. HTML responses fail too, becausejqcannot parse them.set -o pipefailmatters if you pipecurl | jq: without it, the pipeline's exit status isjq's alone and a failed request can go unnoticed.- Don't rely on a specific
curl --failexit code for HTTP errors. With curl 8.7.1, a 500 response gave exit code 22 over HTTP/1.1 but 56 over HTTP/2. Checking%{http_code}as above avoids the difference.
The same check in Node.js
Node.js 18 and later include fetch. This version was tested on Node.js 24.21.0:
// Usage: node check-api.mjs URL
const url = process.argv[2];
const maxMs = 2000;
const started = performance.now();
let res;
try {
res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
} catch (err) {
console.error(`FAIL ${url}: ${err.message}`);
process.exit(1);
}
const ms = Math.round(performance.now() - started);
const fail = (why) => {
console.error(`FAIL ${url}: ${why}`);
process.exit(1);
};
if (!res.ok) fail(`HTTP ${res.status}`);
if (!res.headers.get("content-type")?.includes("application/json"))
fail(`expected JSON, got ${res.headers.get("content-type")}`);
const body = await res.json();
if (typeof body.slideshow?.title !== "string") fail("slideshow.title missing");
if (ms > maxMs) fail(`slow response (${ms} ms)`);
console.log(`OK ${url}: HTTP ${res.status} in ${ms} ms`);Pointed at https://example.com, it reports FAIL https://example.com: expected JSON, got text/html; charset=utf-8. Replace the slideshow.title line with an assertion about your own response.
Running checks on a schedule
A script only helps if something runs it and tells you when it fails. Common options are cron on a server, a scheduled CI job, or a serverless function on a timer. Keep two limits in mind:
- Location. A check that runs next to the API can pass while users elsewhere cannot reach it because of DNS, a CDN or a firewall. Run at least one check from outside your network.
- Who watches the watcher. If the machine running your checks goes down, you get silence, not an alert. Hosted monitors, or a heartbeat service that alerts when a check stops reporting, cover this gap.
Hosted monitors: free tiers compared
These figures come from each vendor's own pricing and documentation pages, checked on 2026-10-28. Plans change, so confirm on the linked page before you rely on a limit.
| Service | Free tier (as published) | Notes |
|---|---|---|
| UptimeRobot | 50 monitors, 5-minute interval | Keyword monitors work on the free plan. In a free account (checked 2026-10-28), SSL and domain expiry details, heartbeat monitors and API monitors with JSON assertions are marked as paid features. Described as "good for hobby and non-profit projects". |
| Better Stack | 10 monitors and heartbeats, 1 status page | Free for personal projects, with Slack and e-mail alerts. Its API monitor supports request headers and HTTP authentication. The free-tier check interval is not stated on the pricing page. |
| Checkly | Hobby: 10 uptime monitors, 10,000 API and 1,000 browser check runs per month | Assertions cover status code, JSON body, text body, headers and response time. Checks stop when the monthly runs are used up. Alerts by e-mail, Slack and webhooks. |
| Uptime Kuma | Self-hosted, no plan limits | Open source (MIT). Supports HTTP keyword and JSON query monitors and many notification services. You run and maintain the server, which should not be the same machine as the API. |
| Healthchecks.io | Hobbyist: 20 jobs | A heartbeat monitor for cron jobs and workers, not an endpoint prober: your job pings it, and it alerts when the pings stop. The code is open source (BSD-3-Clause) and can be self-hosted. |
This site itself is monitored with UptimeRobot (affiliate link, see the disclosure below) on the free plan: two HTTP monitors, plus a keyword monitor that alerts when the JSON formatter page stops containing the text "JSON Formatter". That catches a broken deploy that still returns 200 OK. The results are on a public status page.
Disclosure: the UptimeRobot link in the paragraph above is an affiliate link. If you buy a paid plan after following it, this site earns a commission, at no extra cost to you. It did not change the comparison: every figure in the table comes from the vendors' own pages, and this site has no affiliate or sponsorship relationship with the other services listed.
Choosing
- One public endpoint, status and keyword checks: any hosted free tier above is enough.
- Assertions on JSON values or response time: look for explicit JSON assertions (Checkly's assertions, Uptime Kuma's JSON query monitor), or run the script above on a schedule.
- Data must stay in your infrastructure: self-host Uptime Kuma, on a different machine or provider from the API.
- Scheduled jobs: use a heartbeat (Healthchecks.io, or the heartbeat monitors some uptime services include).
When a check fails
Save the failing response body. Paste it into the JSON formatter to see its structure, and compare it with a known-good response in the JSON diff tool, ignoring volatile keys such as timestamps and request IDs. For the full troubleshooting workflow, see how to debug API responses.